From 91b6dd0e4caafd0279accd5f95b941e3bc3f98d8 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 17:15:02 +0900 Subject: [PATCH 1/6] feat(migrate): migrate transpile-only ts-node scripts to vpx --- .../.fixture/ts-node/package.json | 4 + .../.fixture/typescript/package.json | 4 + .../fixtures/migration_ts_node/package.json | 13 + .../migration_ts_node/scripts/release.ts | 3 + .../migration_ts_node/scripts/seed.ts | 4 + .../fixtures/migration_ts_node/snapshots.toml | 43 +++ .../migration_ts_node_already_vite_plus.md | 61 +++ .../snapshots/migration_ts_node_config.md | 50 +++ .../snapshots/migration_ts_node_kept.md | 41 ++ .../snapshots/migration_ts_node_scripts.md | 59 +++ docs/guide/migrate-rules.md | 45 ++- docs/guide/migrate.md | 2 +- docs/guide/vpx.md | 4 + .../__tests__/ts-node-config.spec.ts | 305 +++++++++++++++ .../src/migration/__tests__/ts-node.spec.ts | 293 ++++++++++++++ packages/cli/src/migration/bin.ts | 34 ++ packages/cli/src/migration/migrator.ts | 2 + packages/cli/src/migration/migrator/README.md | 2 + .../src/migration/migrator/script-runner.ts | 71 +++- .../src/migration/migrator/ts-node-config.ts | 359 ++++++++++++++++++ .../cli/src/migration/migrator/ts-node.ts | 140 +++++++ packages/cli/src/migration/migrator/tsx.ts | 12 +- packages/cli/src/migration/report.ts | 2 + rfcs/vpx-script-execution.md | 2 +- 24 files changed, 1528 insertions(+), 27 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/ts-node/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/typescript/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/release.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/seed.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md create mode 100644 packages/cli/src/migration/__tests__/ts-node-config.spec.ts create mode 100644 packages/cli/src/migration/__tests__/ts-node.spec.ts create mode 100644 packages/cli/src/migration/migrator/ts-node-config.ts create mode 100644 packages/cli/src/migration/migrator/ts-node.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/ts-node/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/ts-node/package.json new file mode 100644 index 0000000000..ad8c89fa1f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/ts-node/package.json @@ -0,0 +1,4 @@ +{ + "name": "ts-node", + "version": "10.9.2" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/typescript/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/typescript/package.json new file mode 100644 index 0000000000..227ad101c6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/.fixture/typescript/package.json @@ -0,0 +1,4 @@ +{ + "name": "typescript", + "version": "5.9.3" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json new file mode 100644 index 0000000000..eef6aefd90 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-ts-node", + "type": "module", + "scripts": { + "seed": "ts-node --transpileOnly ./scripts/seed.ts", + "loader": "node --loader ts-node/esm/transpile-only ./scripts/seed.ts" + }, + "devDependencies": { + "ts-node": "file:.fixture/ts-node", + "typescript": "file:.fixture/typescript", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/release.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/release.ts new file mode 100644 index 0000000000..a91b4dbb7f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/release.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env -S ts-node --transpileOnly +const version: string = '1.0.0'; +console.log(version); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/seed.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/scripts/seed.ts new file mode 100644 index 0000000000..4d62cf3acd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/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_ts_node/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml new file mode 100644 index 0000000000..90006455c5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml @@ -0,0 +1,43 @@ +[[case]] +name = "migration_ts_node_scripts" +vp = "local" +comment = "Local stub dependencies keep the runner migration independent of npm packages." +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "transpile-only commands, loaders, and shebangs migrate to vpx" }, + ["vpt", "print-file", "package.json"], + ["vpt", "print-file", "scripts/release.ts"], + { argv = ["vp", "run", "seed"], comment = "the migrated script runs TypeScript without ts-node" }, + ["vp", "run", "loader"], +] + +[[case]] +name = "migration_ts_node_kept" +vp = "local" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "scripts.checked", "ts-node ./scripts/seed.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "commands that type-check keep ts-node and explain the difference" }, + ["vpt", "print-file", "package.json"], +] + +[[case]] +name = "migration_ts_node_config" +vp = "local" +steps = [ + { argv = ["vpt", "rm", "scripts/release.ts"], snapshot = false }, + { argv = ["vpt", "write-file", "tsconfig.json", "{\"ts-node\":{\"transpileOnly\":true}}\n"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.seed", "ts-node ./scripts/seed.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "tsconfig can explicitly disable ts-node type-checking" }, + ["vpt", "print-file", "package.json"], + ["vp", "run", "seed"], +] + +[[case]] +name = "migration_ts_node_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 ts-node migration to --full" }, + { argv = ["vp", "migrate", "--no-interactive", "--full", "--no-hooks", "--no-agent", "--no-editor"], comment = "--full migrates transpile-only scripts 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_ts_node/snapshots/migration_ts_node_already_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md new file mode 100644 index 0000000000..0b2661b57a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md @@ -0,0 +1,61 @@ +# migration_ts_node_already_vite_plus + +## `vpt json-edit package.json devDependencies.vite-plus latest` + + +## `vp migrate --no-interactive` + +a plain upgrade leaves ts-node 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 transpile-only scripts 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. +● ts-node scripts detected. Auto-migrating supported commands to vpx... +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• ts-node scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +``` +{ + "devDependencies": { + "typescript": "file:.fixture/typescript", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-ts-node", + "scripts": { + "loader": "vpx ./scripts/seed.ts", + "seed": "vpx ./scripts/seed.ts" + }, + "type": "module" +} +``` + +## `vp run seed` + +``` +$ vpx ./scripts/seed.ts ⊘ cache disabled +ready +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md new file mode 100644 index 0000000000..6a46f5d4c3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md @@ -0,0 +1,50 @@ +# migration_ts_node_config + +## `vpt rm scripts/release.ts` + + +## `vpt write-file tsconfig.json '{"ts-node":{"transpileOnly":true}} +'` + + +## `vpt json-edit package.json scripts.seed 'ts-node ./scripts/seed.ts'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +tsconfig can explicitly disable ts-node type-checking + +``` +VITE+ - The Unified Toolchain for the Web + +● ts-node scripts detected. Auto-migrating supported commands to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• ts-node scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +``` +{ + "devDependencies": { + "typescript": "file:.fixture/typescript", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-ts-node", + "scripts": { + "loader": "vpx --tsconfig=tsconfig.json ./scripts/seed.ts", + "seed": "vpx --tsconfig=tsconfig.json ./scripts/seed.ts" + }, + "type": "module" +} +``` + +## `vp run seed` + +``` +$ vpx --tsconfig=tsconfig.json ./scripts/seed.ts ⊘ cache disabled +ready +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md new file mode 100644 index 0000000000..600ef29460 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md @@ -0,0 +1,41 @@ +# migration_ts_node_kept + +## `vpt json-edit package.json scripts.checked 'ts-node ./scripts/seed.ts'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +commands that type-check keep ts-node and explain the difference + +``` +VITE+ - The Unified Toolchain for the Web + +● ts-node scripts detected. Auto-migrating supported commands to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• ts-node scripts migrated to `vpx` +! Warnings: + - Kept ts-node in package.json scripts "checked": this ts-node command type-checks before execution; `vpx` does not type-check. Keep a separate type-check command before changing this script. + - Kept the ts-node devDependency in package.json: `scripts` in package.json still uses ts-node. +``` + +## `vpt print-file package.json` + +``` +{ + "devDependencies": { + "ts-node": "file:.fixture/ts-node", + "typescript": "file:.fixture/typescript", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-ts-node", + "scripts": { + "checked": "ts-node ./scripts/seed.ts", + "loader": "vpx ./scripts/seed.ts", + "seed": "vpx ./scripts/seed.ts" + }, + "type": "module" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md new file mode 100644 index 0000000000..7c432a35d2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md @@ -0,0 +1,59 @@ +# migration_ts_node_scripts + +Local stub dependencies keep the runner migration independent of npm packages. + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +transpile-only commands, loaders, and shebangs migrate to vpx + +``` +VITE+ - The Unified Toolchain for the Web + +● ts-node scripts detected. Auto-migrating supported commands to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• ts-node scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +``` +{ + "name": "migration-ts-node", + "type": "module", + "scripts": { + "seed": "vpx ./scripts/seed.ts", + "loader": "vpx ./scripts/seed.ts" + }, + "devDependencies": { + "typescript": "file:.fixture/typescript", + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` + +## `vpt print-file scripts/release.ts` + +``` +#!/usr/bin/env -S vpx +const version: string = '1.0.0'; +console.log(version); +``` + +## `vp run seed` + +the migrated script runs TypeScript without ts-node + +``` +$ vpx ./scripts/seed.ts ⊘ cache disabled +ready +``` + +## `vp run loader` + +``` +$ vpx ./scripts/seed.ts ⊘ cache disabled +ready +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index bff7150b9b..1b37c77ad1 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -250,19 +250,20 @@ The `prefer-vite-plus-imports` lint rule follows the same Nuxt exception, so lin Migration rewrites commands provided by the Vite+ toolchain in `package.json` scripts while preserving their arguments: -| Before | After | -| ------------- | ------------------------------------------- | -| `vite` | `vp dev`, or the matching `vp` subcommand | -| `vitest` | `vp test` | -| `oxlint` | `vp lint` | -| `oxfmt` | `vp fmt` | -| `tsdown` | `vp pack` | -| `lint-staged` | `vp staged` | -| `eslint` | `vp lint`, when its optional migration runs | -| `prettier` | `vp fmt`, when its optional migration runs | -| `tsup` | `vp pack`, when its optional migration runs | -| `tsx` | `vpx`, when its optional migration runs | -| `oxnode` | `vpx`, when its optional migration runs | +| Before | After | +| ------------- | -------------------------------------------- | +| `vite` | `vp dev`, or the matching `vp` subcommand | +| `vitest` | `vp test` | +| `oxlint` | `vp lint` | +| `oxfmt` | `vp fmt` | +| `tsdown` | `vp pack` | +| `lint-staged` | `vp staged` | +| `eslint` | `vp lint`, when its optional migration runs | +| `prettier` | `vp fmt`, when its optional migration runs | +| `tsup` | `vp pack`, when its optional migration runs | +| `tsx` | `vpx`, when its optional migration runs | +| `oxnode` | `vpx`, when its optional migration runs | +| `ts-node` | `vpx`, for supported transpile-only commands | 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 `--`: @@ -324,6 +325,24 @@ Node.js preload commands also stay unchanged when another startup preload can ch 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. +### ts-node to `vpx` + +Migration offers to replace supported ts-node commands with `vpx` only when type-checking is explicitly disabled. Plain `ts-node` type-checks before execution; `vpx` does not. Commands that still type-check stay unchanged with a warning, even when another option enables transpile-only mode. + +| Before | After | +| ------------------------------------------------------------ | -------------------------------------------------------- | +| `ts-node --transpileOnly ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| `ts-node -T -P tsconfig.scripts.json ./scripts/seed.ts` | `vpx --tsconfig=tsconfig.scripts.json ./scripts/seed.ts` | +| `ts-node-transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| `node -r ts-node/register/transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| `node --loader ts-node/esm/transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | + +The migration also reads `transpileOnly` and `typeCheck` from local JSONC tsconfig files, local `extends` chains, and literal `TS_NODE_TRANSPILE_ONLY` / `TS_NODE_TYPE_CHECK` assignments. `-P`, `--project`, and `TS_NODE_PROJECT` select the configuration. A directory passed to `-P` or `--project` resolves to its `tsconfig.json`; a directory in `TS_NODE_PROJECT` stays unchanged. When ts-node discovers a config implicitly, migration pins that file with `--tsconfig` so `vpx` uses it for the whole run. `ts-node-esm` and `--esm` are supported when type-checking is disabled. + +Custom compilers, transpilers, SWC, ts-node compiler overrides, unsupported configuration inheritance, eval, and REPL usage stay unchanged. Environment files, Node.js config files, additional preloads, and nonempty `NODE_OPTIONS` assignments also keep the original command because they can change ts-node settings before it registers its loader. Migration checks named scripts for known module incompatibilities; it does not follow their imports. Migrated scripts use the [tsconfig and module rules of `vpx`](/guide/vpx#tsconfig). + +As with oxnode, migration rewrites package scripts, JSON lint-staged tasks, and supported env shebangs. Shebangs that need a pinned config stay unchanged because the config path depends on the caller's working directory. Existing Vite+ projects use `vp migrate --full`. The `ts-node` development dependency is removed only when no remaining command, loader, API import, or config reference needs it. Runtime and peer dependencies remain, and `typescript` is not removed. + ## 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 eba4305cfe..a5abab9cdd 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, tsx, and oxnode setups into Vite+. +This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, tsdown, tsup, tsx, oxnode, and ts-node 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 d9cd0ab763..30a220d3a0 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -232,6 +232,10 @@ JSX that depends on a compiler plugin is not supported. `vpx` compiles JSX only `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). +### Coming from ts-node + +`vp migrate` can replace supported transpile-only ts-node commands with `vpx`. It keeps commands that type-check because `vpx` does not perform that check. Custom compiler settings and remaining loader or API usage can also keep `ts-node` installed. See [ts-node to `vpx`](/guide/migrate-rules#ts-node-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__/ts-node-config.spec.ts b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts new file mode 100644 index 0000000000..c84de269af --- /dev/null +++ b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts @@ -0,0 +1,305 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { prepareTsNodeRun } from '../migrator/ts-node-config.ts'; + +describe('prepareTsNodeRun', () => { + let cwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-ts-node-config-')); + fs.mkdirSync(path.join(cwd, 'scripts')); + fs.writeFileSync(path.join(cwd, 'scripts/a.ts'), 'export {};\n'); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + function config(value: unknown, file = 'tsconfig.json') { + fs.writeFileSync(path.join(cwd, file), JSON.stringify(value)); + } + + function prepare(args: string[], environment: string[] = [], executable = 'ts-node') { + return prepareTsNodeRun(args, { + cwd, + environment, + executable, + nodeLoader: executable === 'node', + }); + } + + it.each(['-T', '--transpileOnly', '--transpile-only'])( + 'removes %s while leaving script arguments intact', + (flag) => { + expect(prepare([flag, 'scripts/a.ts', '--typeCheck'])).toEqual({ edits: [{ index: 0 }] }); + }, + ); + + it('keeps commands that type-check by default, including the ESM binary', () => { + for (const executable of ['ts-node', 'ts-node-esm']) { + expect(prepare(['scripts/a.ts'], [], executable).keepReason).toContain('type-checks'); + } + expect(prepare(['scripts/a.ts'], [], 'ts-node-transpile-only')).toEqual({ edits: [] }); + }); + + it('reads transpile-only mode from the nearest entrypoint config', () => { + config({ 'ts-node': { transpileOnly: false } }); + config({ 'ts-node': { transpileOnly: true } }, 'scripts/tsconfig.json'); + expect(prepare(['scripts/a.ts'])).toEqual({ + edits: [], + prependArgs: ['--tsconfig=scripts/tsconfig.json'], + }); + expect(prepare(['--esm', 'scripts/a.ts'])).toEqual({ + edits: [{ index: 0 }], + prependArgs: ['--tsconfig=scripts/tsconfig.json'], + }); + }); + + it('reads JSONC and local inherited ts-node settings', () => { + config({ 'ts-node': { transpileOnly: true, files: true } }, 'base.json'); + fs.writeFileSync( + path.join(cwd, 'tsconfig.json'), + '{ // project config\n"extends": "./base", "ts-node": { "esm": true, }, }', + ); + expect(prepare(['scripts/a.ts'])).toEqual({ + edits: [], + prependArgs: ['--tsconfig=tsconfig.json'], + }); + }); + + it('merges environment, inherited config, and CLI in order', () => { + config({ 'ts-node': { transpileOnly: true } }, 'base.json'); + config({ extends: './base.json', 'ts-node': { transpileOnly: false } }); + expect(prepare(['scripts/a.ts'], ['TS_NODE_TRANSPILE_ONLY=true']).keepReason).toContain( + 'type-checks', + ); + expect(prepare(['-T', 'scripts/a.ts'], ['TS_NODE_TRANSPILE_ONLY=false'])).toEqual({ + edits: [{ index: 0 }], + prependArgs: ['--tsconfig=tsconfig.json'], + }); + }); + + it.each([ + 'TS_NODE_TRANSPILE_ONLY=true', + 'TS_NODE_TRANSPILE_ONLY=1', + 'TS_NODE_TRANSPILE_ONLY=yes', + ])('recognizes %s', (assignment) => { + expect(prepare(['scripts/a.ts'], [assignment])).toEqual({ edits: [] }); + }); + + it('keeps type checking even when transpile-only is also enabled', () => { + expect(prepare(['-T', '--typeCheck', 'scripts/a.ts']).keepReason).toContain('type-checks'); + expect(prepare(['-T', 'scripts/a.ts'], ['TS_NODE_TYPE_CHECK=true']).keepReason).toContain( + 'type-checks', + ); + config({ 'ts-node': { typeCheck: true } }); + expect(prepare(['-T', 'scripts/a.ts']).keepReason).toContain('type-checks'); + expect( + prepare(['--require', 'ts-node/register/transpile-only', 'scripts/a.ts'], [], 'node') + .keepReason, + ).toContain('type-checks'); + expect(prepare(['-T', '--type-check=false', 'scripts/a.ts']).keepReason).toContain( + 'enabled flag', + ); + }); + + it.each([ + 'ts-node/register/transpile-only', + 'ts-node/esm/transpile-only', + 'ts-node/esm/transpile-only.mjs', + ])('recognizes the explicit transpile-only loader %s', (loader) => { + expect(prepare(['--loader', loader, 'scripts/a.ts'], [], 'node')).toEqual({ edits: [] }); + }); + + it('finds a Node preload configuration from cwd rather than the script directory', () => { + config({ 'ts-node': { transpileOnly: true } }); + config({ 'ts-node': { typeCheck: true } }, 'scripts/tsconfig.json'); + expect(prepare(['--require=ts-node/register', 'scripts/a.ts'], [], 'node')).toEqual({ + edits: [], + prependArgs: ['--tsconfig=tsconfig.json'], + }); + }); + + it.each(['-P', '--project', '--project='])('maps %s to a checked tsconfig file', (flag) => { + config({ 'ts-node': { transpileOnly: true } }, 'scripts/config.json'); + const inline = flag.endsWith('='); + const args = inline ? [`${flag}scripts/config.json`] : [flag, 'scripts/config.json']; + const expected = [{ index: 0, value: '--tsconfig=scripts/config.json' }]; + expect(prepare([...args, 'scripts/a.ts'])).toEqual({ + edits: inline ? expected : [...expected, { index: 1 }], + }); + }); + + it('maps project directories and gives CLI project selection precedence over the environment', () => { + config({ 'ts-node': { transpileOnly: true } }, 'scripts/tsconfig.json'); + expect(prepare(['-P', 'scripts', 'scripts/a.ts'], ['TS_NODE_PROJECT=missing.json'])).toEqual({ + edits: [ + { index: 0, value: `--tsconfig=${path.join('scripts', 'tsconfig.json')}` }, + { index: 1 }, + ], + }); + expect(prepare(['scripts/a.ts'], ['TS_NODE_PROJECT=scripts/tsconfig.json'])).toEqual({ + edits: [], + }); + expect(prepare(['scripts/a.ts'], ['TS_NODE_PROJECT=scripts']).keepReason).toContain( + 'names a directory', + ); + }); + + it('keeps relative project paths portable when the working directory contains spaces', () => { + const spaced = path.join(cwd, 'my project'); + fs.mkdirSync(spaced); + config({ 'ts-node': { transpileOnly: true } }, 'my project/ts config.json'); + const result = prepareTsNodeRun(['-P', './ts config.json', 'a.ts'], { + cwd: spaced, + environment: [], + executable: 'ts-node', + nodeLoader: false, + }); + expect(result).toEqual({ + edits: [{ index: 0, value: '--tsconfig=./ts config.json' }, { index: 1 }], + }); + expect(JSON.stringify(result)).not.toContain(cwd); + }); + + it('keeps repeated project flags instead of leaving one untranslated', () => { + expect(prepare(['-T', '-P', 'one.json', '-P', 'two.json', 'a.ts']).keepReason).toContain( + 'more than once', + ); + }); + + it.each([ + ['--swc'], + ['-C', 'ttypescript'], + ['-O', '{"module":"CommonJS"}'], + ['--transpiler', 'custom'], + ['--skipProject'], + ['--cwdMode'], + ['--showConfig'], + ['--unknown'], + ])('keeps unsupported ts-node options %s', (...flags) => { + expect(prepare(['-T', ...flags, 'scripts/a.ts']).keepReason).toContain('does not translate'); + }); + + it.each([ + { compilerOptions: { module: 'CommonJS' } }, + { require: ['./setup.ts'] }, + { moduleTypes: { '**/*.ts': 'cjs' } }, + { swc: true }, + { transpiler: 'custom' }, + { experimentalResolver: true }, + ])('keeps ts-node-specific config %j', (settings) => { + config({ 'ts-node': { transpileOnly: true, ...settings } }); + expect(prepare(['scripts/a.ts']).keepReason).toContain('does not translate'); + }); + + it.each(['TS_NODE_COMPILER_OPTIONS={}', 'TS_NODE_REQUIRE=./setup.ts', 'TS_NODE_SKIP_PROJECT=1'])( + 'keeps unsupported environment setting %s', + (setting) => { + expect(prepare(['-T', 'scripts/a.ts'], [setting]).keepReason).toContain('does not translate'); + }, + ); + + it('keeps config failures and unresolved inheritance instead of assuming type checking is off', () => { + expect(prepare(['-T', '-P', 'missing.json', 'scripts/a.ts']).keepReason).toContain( + 'cannot be read', + ); + config({ extends: 'ts-node/node16/tsconfig.json' }); + expect(prepare(['-T', 'scripts/a.ts']).keepReason).toContain('cannot resolve'); + config({ extends: './tsconfig.json' }); + expect(prepare(['-T', 'scripts/a.ts']).keepReason).toContain('circular'); + fs.writeFileSync(path.join(cwd, 'tsconfig.json'), '{ "ts-node":'); + expect(prepare(['-T', 'scripts/a.ts']).keepReason).toContain('cannot be parsed'); + }); + + it('keeps commands when a shell state change makes their config unknown', () => { + expect( + prepareTsNodeRun(['-T', 'scripts/a.ts'], { + environment: [], + executable: 'ts-node', + nodeLoader: false, + }).keepReason, + ).toContain('working directory is unknown'); + }); + + it('preserves additional Node options in loader mode', () => { + expect( + prepare( + ['--conditions', 'dev', '--require', 'ts-node/register/transpile-only', 'scripts/a.ts'], + [], + 'node', + ), + ).toEqual({ edits: [] }); + }); + + it.each(['--env-file', '--env-file-if-exists', '--env_file', '--env_file_if_exists'])( + 'keeps a Node loader with %s because the file can enable type checking', + (flag) => { + fs.writeFileSync(path.join(cwd, '.env'), 'TS_NODE_TYPE_CHECK=true\n'); + for (const envArgs of [[flag, '.env'], [`${flag}=.env`]]) { + expect( + prepare( + ['--require', 'ts-node/register/transpile-only', ...envArgs, 'scripts/a.ts'], + [], + 'node', + ).keepReason, + ).toContain('can configure ts-node'); + } + }, + ); + + it.each(['./setup.cjs', 'dotenv/config'])( + 'keeps the additional preload %s because it can enable type checking', + (preload) => { + fs.writeFileSync(path.join(cwd, 'setup.cjs'), "process.env.TS_NODE_TYPE_CHECK = 'true';\n"); + expect( + prepare( + ['-r', preload, '-r', 'ts-node/register/transpile-only', 'scripts/a.ts'], + [], + 'node', + ).keepReason, + ).toContain('additional preload'); + expect(prepare(['-T', '-r', preload, 'scripts/a.ts']).keepReason).toContain( + 'additional preload', + ); + }, + ); + + it.each([ + '--config-file=node.config.json', + '--experimental-config-file=node.config.json', + '--experimental-default-config-file', + '--experimental_config_file=node.config.json', + ])('keeps Node configuration option %s because it can load preloads', (flag) => { + expect( + prepare([flag, '-r', 'ts-node/register/transpile-only', 'scripts/a.ts'], [], 'node') + .keepReason, + ).toContain('can load preloads'); + }); + + it('retains noncanonical environment names instead of disabling POSIX type checking', () => { + expect(prepare(['scripts/a.ts'], ['TS_NODE_transpile_only=true']).keepReason).toContain( + 'casing', + ); + }); + + it('follows entrypoint symlinks when deciding whether type checking is enabled', () => { + fs.mkdirSync(path.join(cwd, 'actual')); + config({ 'ts-node': { typeCheck: true } }, 'actual/tsconfig.json'); + config({ 'ts-node': { transpileOnly: true } }, 'scripts/tsconfig.json'); + fs.writeFileSync(path.join(cwd, 'actual/a.ts'), 'export {};\n'); + fs.rmSync(path.join(cwd, 'scripts/a.ts')); + fs.symlinkSync(path.join(cwd, 'actual/a.ts'), path.join(cwd, 'scripts/a.ts')); + expect(prepare(['-T', 'scripts/a.ts']).keepReason).toContain('type-checks'); + }); + + it('retains unavailable and glob entrypoints instead of guessing their config', () => { + for (const entry of ['scripts/missing.ts', 'scripts/*/*.ts']) { + expect(prepare(['-T', entry]).keepReason).toContain('cannot be resolved'); + } + }); +}); diff --git a/packages/cli/src/migration/__tests__/ts-node.spec.ts b/packages/cli/src/migration/__tests__/ts-node.spec.ts new file mode 100644 index 0000000000..a62fe7aff3 --- /dev/null +++ b/packages/cli/src/migration/__tests__/ts-node.spec.ts @@ -0,0 +1,293 @@ +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 { + detectTsNodeProject, + migrateTsNodeToVpx, + rewriteTsNodeCommand, +} from '../migrator/ts-node.ts'; +import { createMigrationReport } from '../report.ts'; + +describe('ts-node 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-ts-node-')); + vi.spyOn(process, 'cwd').mockReturnValue(root); + write('package.json', { type: 'module' }); + for (const file of ['a.ts', 'b.ts', 'setup.ts', 'scripts/a.ts']) { + write(file, 'export {};\n'); + } + write('a.cts', 'exports.value = 1;\n'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each([ + ['ts-node -T a.ts --flag', 'vpx a.ts --flag'], + ['ts-node --transpileOnly a.ts', 'vpx a.ts'], + ['ts-node --transpile-only --esm a.ts', 'vpx a.ts'], + ['ts-node-esm -T a.ts', 'vpx a.ts'], + ['ts-node-transpile-only a.ts', 'vpx a.ts'], + ['pnpm exec ts-node -T a.ts', 'vpx a.ts'], + ['npx --yes ts-node -T a.ts', 'vpx a.ts'], + ['npm exec -- ts-node -T a.ts', 'vpx a.ts'], + ['yarn ts-node -T a.ts', 'vpx a.ts'], + ['bunx ts-node -T a.ts', 'vpx a.ts'], + ['node -r ts-node/register/transpile-only a.ts', 'vpx a.ts'], + ['node --require=ts-node/register/transpile-only a.cts', 'vpx a.cts'], + ['node --loader ts-node/esm/transpile-only a.ts', 'vpx a.ts'], + ['node --loader=ts-node/esm/transpile-only.mjs --inspect a.ts', 'vpx --inspect a.ts'], + ['node -C dev -r ts-node/register/transpile-only a.ts', 'vpx -C dev a.ts'], + [ + 'cross-env TS_NODE_TRANSPILE_ONLY=true ts-node a.ts', + 'cross-env TS_NODE_TRANSPILE_ONLY=true vpx a.ts', + ], + ['ts-node -T a.ts && ts-node-transpile-only b.ts', 'vpx a.ts && vpx b.ts'], + [ + 'concurrently "ts-node -T a.ts" "ts-node-transpile-only b.ts"', + 'concurrently "vpx a.ts" "vpx b.ts"', + ], + ["nodemon --exec 'ts-node -T a.ts'", "nodemon --exec 'vpx a.ts'"], + [ + 'TS_NODE_TRANSPILE_ONLY=1 concurrently "ts-node a.ts"', + 'TS_NODE_TRANSPILE_ONLY=1 concurrently "vpx a.ts"', + ], + ])('rewrites %s and is idempotent', (command, expected) => { + expect(rewriteTsNodeCommand(command, root)).toEqual({ command: expected, kept: [] }); + expect(rewriteTsNodeCommand(expected, root)).toEqual({ command: expected, kept: [] }); + }); + + it.each([ + ['ts-node a.ts', 'type-checks'], + ['ts-node-esm a.ts', 'type-checks'], + ['node -r ts-node/register a.ts', 'type-checks'], + ['ts-node -T --typeCheck a.ts', 'type-checks'], + ['ts-node -T --typeCheck=false a.ts', 'enabled flag'], + ['TS_NODE_TYPE_CHECK=true concurrently "ts-node -T a.ts"', 'type-checks'], + ['TS_NODE_transpile_only=true ts-node a.ts', 'casing'], + ['ts-node --swc a.ts', 'does not translate'], + ['ts-node -T -C ttypescript a.ts', 'does not translate'], + ['ts-node -T --compiler-options "{}" a.ts', 'does not translate'], + ['ts-node -T watch a.ts', 'cannot be resolved'], + ['ts-node -T --eval 1', 'does not translate'], + ['ts-node -T', 'name a script'], + ['node -r ts-node/register/transpile-only --watch a.ts', 'different watchers'], + ['node -r ts-node/register/transpile-only --env-file=.env a.ts', 'can configure ts-node'], + ['node --env_file=.env -r ts-node/register/transpile-only a.ts', 'can configure ts-node'], + ['node -r dotenv/config -r ts-node/register/transpile-only a.ts', 'additional preload'], + [ + 'NODE_OPTIONS="-r ./setup.cjs" node -r ts-node/register/transpile-only a.ts', + 'configuration preloads', + ], + ['NODE_OPTIONS="-r dotenv/config" ts-node -T a.ts', 'configuration preloads'], + ['node -r ts-node/register/transpile-only --import ./setup.ts a.ts', 'additional preload'], + [ + 'NODE_OPTIONS=--require=ts-node/register/transpile-only ts-node -T a.ts', + 'hooks that would compete', + ], + ['bunx --bun ts-node -T a.ts', 'does not rewrite'], + ['npx ts-node@10 -T a.ts', 'does not rewrite'], + ['ts-node-cwd -T a.ts', 'does not rewrite'], + ['ts-node -T a.ts | tee output', 'shell syntax'], + ['ts-node -T $ENTRY', 'shell syntax'], + ['cd scripts && ts-node -T a.ts', 'working directory is unknown'], + ])('retains %s', (command, reason) => { + expect(rewriteTsNodeCommand(command, root)).toEqual({ + command, + kept: expect.arrayContaining([expect.stringContaining(reason)]), + }); + }); + + it('pins the config selected by the CLI or Node loader without overlapping loader edits', () => { + write('tsconfig.json', { 'ts-node': { transpileOnly: true } }); + write('scripts/tsconfig.json', { + 'ts-node': { transpileOnly: true }, + compilerOptions: { jsx: 'react' }, + }); + expect(rewriteTsNodeCommand('ts-node scripts/a.ts', root)).toEqual({ + command: 'vpx --tsconfig=scripts/tsconfig.json scripts/a.ts', + kept: [], + }); + for (const loader of [ + '--require ts-node/register', + '--require=ts-node/register/transpile-only', + ]) { + expect(rewriteTsNodeCommand(`node ${loader} scripts/a.ts`, root)).toEqual({ + command: 'vpx --tsconfig=tsconfig.json scripts/a.ts', + kept: [], + }); + } + }); + + it('translates explicit project paths with spaces using portable quotes', () => { + write('config files/tsconfig.json', { 'ts-node': { transpileOnly: true } }); + expect(rewriteTsNodeCommand('ts-node -P "config files" a.ts', root)).toEqual({ + command: 'vpx "--tsconfig=config files/tsconfig.json" a.ts', + kept: [], + }); + expect( + rewriteTsNodeCommand('ts-node --project="config files/tsconfig.json" a.ts', root), + ).toEqual({ + command: 'vpx "--tsconfig=config files/tsconfig.json" a.ts', + kept: [], + }); + }); + + it('retains generated config arguments that require platform-specific escaping', () => { + write("config's.json", { 'ts-node': { transpileOnly: true } }); + const command = 'ts-node -P "config\'s.json" a.ts'; + expect(rewriteTsNodeCommand(command, root)).toEqual({ + command, + kept: [expect.stringContaining('shell escaping')], + }); + }); + + it.each([ + ['a.cts', 'export const value = 1;\n', 'does not compile to CommonJS'], + ['a.ts', "import fs from 'node:fs'; console.log(__dirname);\n", 'CommonJS globals'], + [ + 'setup.ts', + "import { register } from 'ts-node'; register({ typeCheck: true });\n", + 'standalone ts-node API', + ], + ])('checks the source of %s before replacing its loader', (file, content, reason) => { + write('package.json', {}); + write(file, content); + const command = `ts-node -T ${file}`; + expect(rewriteTsNodeCommand(command, root)).toEqual({ + command, + kept: [expect.stringContaining(reason)], + }); + }); + + it('migrates scripts, staged tasks, and config-free shebangs and removes only ts-node', () => { + write('package.json', { + scripts: { seed: 'ts-node -T a.ts' }, + 'lint-staged': { '*.ts': 'ts-node-transpile-only scripts/a.ts' }, + devDependencies: { 'ts-node': '^10.9.2', typescript: '^6.0.0' }, + }); + write('.lintstagedrc.json', { '*.md': 'ts-node -T b.ts' }); + write('scripts/release.ts', '#!/usr/bin/env -S ts-node -T\nexport {};\n'); + write('scripts/direct.ts', '#!/usr/bin/env ts-node-transpile-only\nexport {};\n'); + const report = createMigrationReport(); + expect(detectTsNodeProject(root)).toBe(true); + expect(migrateTsNodeToVpx(root, undefined, report)).toEqual({ + changed: true, + dependencyRemoved: true, + }); + expect(readJson('package.json')).toEqual({ + scripts: { seed: 'vpx a.ts' }, + 'lint-staged': { '*.ts': 'vpx scripts/a.ts' }, + devDependencies: { typescript: '^6.0.0' }, + }); + expect(readJson('.lintstagedrc.json')).toEqual({ '*.md': 'vpx b.ts' }); + expect(read('scripts/release.ts')).toBe('#!/usr/bin/env -S vpx\nexport {};\n'); + expect(read('scripts/direct.ts')).toBe('#!/usr/bin/env vpx\nexport {};\n'); + expect(report.tsNodeMigrated).toBe(true); + expect(report.warnings).toEqual([]); + expect(detectTsNodeProject(root)).toBe(false); + expect(migrateTsNodeToVpx(root)).toEqual({ changed: false, dependencyRemoved: false }); + }); + + it('keeps shebangs whose selected config cannot be pinned independently of caller cwd', () => { + write('package.json', { devDependencies: { 'ts-node': '^10.9.2' } }); + write('scripts/tsconfig.json', { 'ts-node': { transpileOnly: true } }); + const source = '#!/usr/bin/env -S ts-node -T\nexport {};\n'; + write('scripts/release.ts', source); + const report = createMigrationReport(); + expect(migrateTsNodeToVpx(root, undefined, report)).toEqual({ + changed: false, + dependencyRemoved: false, + }); + expect(read('scripts/release.ts')).toBe(source); + expect(report.warnings).toContainEqual(expect.stringContaining('pinned tsconfig')); + }); + + it.each([ + ['src/api.ts', "import { register } from 'ts-node';\n"], + ['dist/api.js', "require('ts-node/register/transpile-only');\n"], + ['src/spawn.ts', "spawn('ts-node-esm', ['a.ts']);\n"], + ['configs/legacy.json', '{"extends":"ts-node/node16/tsconfig.json"}'], + ['.env.local', 'NODE_OPTIONS=--require=ts-node/register\n'], + ['.github/workflows/ci.yml', 'jobs:\n run: ts-node scripts/ci.ts\n'], + ['.husky/pre-commit', 'ts-node -T scripts/check.ts\n'], + ['scripts/release.sh', 'ts-node -T a.ts\n'], + ['.mocharc.json', '{"require":"ts-node/register"}'], + ['lint-staged.config.ts', "export default { '*.ts': 'ts-node -T a.ts' };\n"], + ])('retains the dependency used by %s', (file, content) => { + write('package.json', { + scripts: { seed: 'ts-node -T a.ts' }, + devDependencies: { 'ts-node': '^10.9.2' }, + }); + write(file, content); + const report = createMigrationReport(); + expect(migrateTsNodeToVpx(root, undefined, report).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts.seed).toBe('vpx a.ts'); + expect(readJson('package.json').devDependencies['ts-node']).toBe('^10.9.2'); + expect(report.warnings).toContainEqual(expect.stringContaining(`${file} still uses ts-node`)); + }); + + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( + 'preserves scripts that need ts-node at runtime through %s', + (field) => { + write('package.json', { + scripts: { seed: 'ts-node -T a.ts' }, + [field]: { 'ts-node': '^10.9.2' }, + }); + expect(detectTsNodeProject(root)).toBe(true); + expect(migrateTsNodeToVpx(root).changed).toBe(false); + expect(readJson('package.json').scripts.seed).toBe('ts-node -T a.ts'); + }, + ); + + it('reports projects that contain only type-checking commands', () => { + write('package.json', { + scripts: { seed: 'ts-node a.ts' }, + devDependencies: { 'ts-node': '^10.9.2' }, + }); + const report = createMigrationReport(); + expect(detectTsNodeProject(root)).toBe(true); + expect(migrateTsNodeToVpx(root, undefined, report).changed).toBe(false); + expect(report.tsNodeMigrated).toBe(false); + expect(report.warnings).toContainEqual(expect.stringContaining('type-checks')); + }); + + it('retains workspace and nested-package dependencies until all their users migrate', () => { + write('package.json', { devDependencies: { 'ts-node': '^10.9.2' } }); + write('packages/a/package.json', { + scripts: { seed: 'ts-node -T a.ts' }, + devDependencies: { 'ts-node': '^10.9.2' }, + }); + write('packages/a/a.ts', 'export {};\n'); + write('packages/a/examples/package.json', { imports: { '#loader': 'ts-node/register' } }); + write('packages/b/package.json', { scripts: { seed: 'ts-node a.ts' } }); + write('packages/b/a.ts', 'export {};\n'); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + expect(migrateTsNodeToVpx(root, packages).dependencyRemoved).toBe(false); + expect(readJson('packages/a/package.json').scripts.seed).toBe('vpx a.ts'); + expect(readJson('packages/b/package.json').scripts.seed).toBe('ts-node a.ts'); + expect(readJson('packages/a/package.json').devDependencies['ts-node']).toBe('^10.9.2'); + expect(readJson('package.json').devDependencies['ts-node']).toBe('^10.9.2'); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 695ccd476c..43ade2e5b0 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -51,6 +51,7 @@ import { confirmPrettierMigration, confirmTsxMigration, confirmOxnodeMigration, + confirmTsNodeMigration, detectEslintProject, detectFramework, detectNodeVersionManagerFile, @@ -59,6 +60,7 @@ import { detectTsupProject, detectTsxProject, detectOxnodeProject, + detectTsNodeProject, detectVitePlusBootstrapPending, detectYarnPnpMode, ensureVitePlusBootstrap, @@ -78,6 +80,7 @@ import { migrateTsupToTsdown, migrateTsxToVpx, migrateOxnodeToVpx, + migrateTsNodeToVpx, configureYarnNodeModulesMode, rewriteMonorepo, rewriteStandaloneProject, @@ -289,6 +292,7 @@ interface MigrationPlan extends MigrationSetupPlan { tsupConfigFile?: string; migrateTsx: boolean; migrateOxnode: boolean; + migrateTsNode: boolean; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; @@ -387,6 +391,7 @@ function hasExistingVitePlusMigrationCandidates( tsupProject.hasDependency || detectTsxProject(workspaceInfo.rootDir, workspaceInfo.packages) || detectOxnodeProject(workspaceInfo.rootDir, workspaceInfo.packages) || + detectTsNodeProject(workspaceInfo.rootDir, workspaceInfo.packages) || detectNodeVersionManagerFile(workspaceInfo.rootDir) !== undefined || getFrameworkShimCandidates(workspaceInfo.rootDir, workspaceInfo.packages).length > 0 ); @@ -451,6 +456,8 @@ async function collectMigrationPlan( detectTsxProject(rootDir, packages) && (await confirmTsxMigration(options.interactive)); const migrateOxnode = detectOxnodeProject(rootDir, packages) && (await confirmOxnodeMigration(options.interactive)); + const migrateTsNode = + detectTsNodeProject(rootDir, packages) && (await confirmTsNodeMigration(options.interactive)); // 9. tsconfig baseUrl prompt const fixBaseUrl = hasBaseUrlInWorkspace({ rootDir, packages }) @@ -481,6 +488,7 @@ async function collectMigrationPlan( tsupConfigFile, migrateTsx, migrateOxnode, + migrateTsNode, fixBaseUrl, migrateNodeVersionFile, nodeVersionDetection, @@ -595,6 +603,9 @@ function showMigrationSummary(options: { if (report.oxnodeMigrated) { log(`${styleText('gray', '•')} oxnode scripts migrated to Vite+`); } + if (report.tsNodeMigrated) { + log(`${styleText('gray', '•')} ts-node scripts migrated to \`vpx\``); + } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); } @@ -879,6 +890,10 @@ async function executeMigrationPlan( updateMigrationProgress('Migrating oxnode scripts'); migrateOxnodeToVpx(workspaceInfo.rootDir, workspaceInfo.packages, report); } + if (plan.migrateTsNode) { + updateMigrationProgress('Migrating ts-node scripts'); + migrateTsNodeToVpx(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. @@ -1447,6 +1462,25 @@ async function main() { } } + if ( + fullSetup && + detectTsNodeProject(workspaceInfoOptional.rootDir, workspaceInfoOptional.packages) + ) { + if (options.interactive) { + clearMigrationProgress(); + } + if (await confirmTsNodeMigration(options.interactive)) { + updateMigrationProgress('Migrating ts-node scripts'); + const tsNodeResult = migrateTsNodeToVpx( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packages, + report, + ); + didMigrate ||= tsNodeResult.changed; + needsInstall ||= tsNodeResult.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 c35e126fd6..a74279c936 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -5,6 +5,8 @@ export * from './migrator/tsup.ts'; export * from './migrator/tsx.ts'; export * from './migrator/script-runner.ts'; export * from './migrator/oxnode.ts'; +export * from './migrator/ts-node.ts'; +export * from './migrator/ts-node-config.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 903a463514..acd19f4eb8 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. | +| `ts-node.ts` | ts-node → `vpx` migration, retained type-checking and loader uses, and its prompt/warnings. | +| `ts-node-config.ts` | Conservative ts-node option translation and config/env preflight before script migration. | | `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. | diff --git a/packages/cli/src/migration/migrator/script-runner.ts b/packages/cli/src/migration/migrator/script-runner.ts index f59f724612..ee8301975b 100644 --- a/packages/cli/src/migration/migrator/script-runner.ts +++ b/packages/cli/src/migration/migrator/script-runner.ts @@ -55,11 +55,14 @@ export interface ScriptRunnerMigration { environment: readonly string[]; nodeLoader: boolean; executable: string; + shebang?: boolean; }, ) => { keepReason?: string; /** Argument indices after the executable; no value removes that argument. */ edits?: readonly { index: number; value?: string }[]; + /** Options to add before the existing arguments, without changing their source ranges. */ + prependArgs?: readonly string[]; }; /** Report unsupported uses even when no command can be converted. */ detectKeptUses?: boolean; @@ -77,8 +80,6 @@ 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([ @@ -284,7 +285,7 @@ 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)) { + for (const edit of pending.toSorted((a, b) => b.start - a.start || b.end - a.end)) { const previous = edits.at(-1); if (previous?.text === '' && edit.text === '' && edit.end >= previous.start) { previous.start = Math.min(previous.start, edit.start); @@ -543,6 +544,19 @@ function prepareRun( }, ); const edits: Edit[] = []; + const added = prepared?.prependArgs?.map(quoteArgument) ?? []; + if ( + added.some((value) => value === undefined) || + prepared?.edits?.some( + (edit) => edit.value !== undefined && quoteArgument(edit.value) === undefined, + ) + ) { + return { + tokens, + edits, + keepReason: 'a rewritten argument needs shell escaping that vp migrate does not support', + }; + } const changes = new Map(prepared?.edits?.map((edit) => [start + 1 + edit.index, edit])); const normalized = tokens.flatMap((token, index) => { const change = changes.get(index); @@ -553,15 +567,39 @@ function prepareRun( edits.push(removal(tokens, index)); return []; } - const text = /^[\w./:=@+-]+$/.test(change.value) - ? change.value - : `'${change.value.replaceAll("'", "'\\''")}'`; + const text = quoteArgument(change.value)!; edits.push({ start: token.start, end: token.end, text }); return [{ ...token, value: change.value }]; }); + if (prepared?.prependArgs?.length) { + const position = tokens[start].end; + edits.push({ + start: position, + end: position, + text: ` ${added.join(' ')}`, + }); + normalized.splice( + start + 1, + 0, + ...prepared.prependArgs.map((value) => ({ + ...tokens[start], + value, + start: position, + end: position, + })), + ); + } return { tokens: normalized, edits, keepReason: prepared?.keepReason }; } +function quoteArgument(value: string): string | undefined { + if (/^[\w./:=@+-]+$/.test(value)) { + return value; + } + // Double quotes work in both POSIX shells and cmd.exe for these literal paths. + return /^[\w./:=@+ -]+$/.test(value) ? `"${value}"` : undefined; +} + /** The options before the script entry. */ interface ScannedOptions { /** Where the entry, or with `--test` the test files, start. */ @@ -832,13 +870,30 @@ function rewriteRunnerShebang( } 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 entry = quoteArgument(`./${path.basename(file)}`); + if (entry === undefined) { + return { reason: 'the script path needs shell escaping that vp migrate does not support' }; + } + const shebangRunner = runner.prepareRun + ? { + ...runner, + prepareRun: ( + args: readonly string[], + context: Parameters>[1], + ) => runner.prepareRun!(args, { ...context, shebang: true }), + } + : runner; + const result = rewriteScriptRunnerCommand( + shebangRunner, + `${command.trimEnd()} ${entry}`, + path.dirname(file), + ); 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); + const vpxCommand = result.command.slice(0, -` ${entry}`.length); return { content: `${prefix}${vpxCommand}${content.slice(line.length)}` }; } diff --git a/packages/cli/src/migration/migrator/ts-node-config.ts b/packages/cli/src/migration/migrator/ts-node-config.ts new file mode 100644 index 0000000000..8ac7666470 --- /dev/null +++ b/packages/cli/src/migration/migrator/ts-node-config.ts @@ -0,0 +1,359 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { type ParseError, parse as parseJsonc } from 'jsonc-parser'; + +import { NODE_OPTIONS_WITH_VALUE } from '../../vpx-script.ts'; +import { isPlainRecord } from './shared.ts'; + +interface TsNodeRunContext { + cwd?: string; + environment: readonly string[]; + nodeLoader: boolean; + executable: string; + shebang?: boolean; +} + +interface TsNodeRunPreparation { + keepReason?: string; + /** Indices refer to args, excluding the executable. An absent value removes a token. */ + edits?: { index: number; value?: string }[]; + prependArgs?: string[]; +} + +interface TsNodeOptions { + transpileOnly?: boolean; + typeCheck?: boolean; + esm?: boolean; + files?: boolean; +} + +const KEEP_TYPE_CHECK = + 'this ts-node command type-checks before execution; `vpx` does not type-check. Keep a separate type-check command before changing this script'; +const KEEP_CWD = 'the working directory is unknown, so the ts-node configuration cannot be checked'; +const TS_NODE_LOADER = + /^ts-node\/(?:register(?:\/(?:transpile-only|type-check|files))?|esm(?:\/transpile-only)?(?:\.mjs)?)$/; +const PRELOAD_OPTIONS = new Set([ + '-r', + '--require', + '--import', + '--loader', + '--experimental-loader', +]); +const BOOLEAN_OPTIONS = new Map([ + ['-T', 'transpileOnly'], + ['--transpileOnly', 'transpileOnly'], + ['--transpile-only', 'transpileOnly'], + ['--typeCheck', 'typeCheck'], + ['--type-check', 'typeCheck'], + ['--esm', 'esm'], + ['--files', 'files'], +]); +const CONFIG_OPTIONS = new Set(['transpileOnly', 'typeCheck', 'esm', 'files']); + +function booleanEnvironment(value: string): boolean | undefined { + if (/^(?:y|yes|true|1)$/i.test(value)) { + return true; + } + if (/^(?:n|no|false|0)$/i.test(value)) { + return false; + } + return undefined; +} + +function nearestTsconfig(dir: string): string | undefined { + for (;;) { + const file = path.join(dir, 'tsconfig.json'); + if (fs.existsSync(file)) { + return file; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +/** Read only data; migration must not load ts-node or a project's compiler. */ +function readTsNodeOptions( + file: string, + ancestors = new Set(), +): { options?: TsNodeOptions; keepReason?: string } { + const keepConfig = (reason: string) => ({ + keepReason: `the ts-node configuration in \`${file}\` ${reason}`, + }); + if (ancestors.has(file)) { + return keepConfig('has circular inheritance'); + } + const chain = new Set(ancestors).add(file); + let config: unknown; + try { + const errors: ParseError[] = []; + config = parseJsonc(fs.readFileSync(file, 'utf8'), errors, { allowTrailingComma: true }); + if (errors.length || !isPlainRecord(config)) { + return keepConfig('cannot be parsed'); + } + } catch { + return keepConfig('cannot be read'); + } + let inherited: TsNodeOptions = {}; + if (config.extends !== undefined) { + if ( + typeof config.extends !== 'string' || + (!config.extends.startsWith('.') && !path.isAbsolute(config.extends)) + ) { + return keepConfig('uses inheritance that vp migrate cannot resolve'); + } + let base = path.resolve(path.dirname(file), config.extends); + if (!fs.existsSync(base) && !base.endsWith('.json')) { + base += '.json'; + } + const result = readTsNodeOptions(base, chain); + if (result.keepReason) { + return result; + } + inherited = result.options ?? {}; + } + const settings = config['ts-node']; + if (settings === undefined) { + return { options: inherited }; + } + if (!isPlainRecord(settings)) { + return keepConfig('has an invalid `ts-node` object'); + } + for (const [key, value] of Object.entries(settings)) { + if (!CONFIG_OPTIONS.has(key)) { + return keepConfig(`uses \`ts-node.${key}\`, which vp migrate does not translate`); + } + if (typeof value !== 'boolean') { + return keepConfig(`has a non-boolean \`ts-node.${key}\` value`); + } + } + return { options: { ...inherited, ...settings } }; +} + +/** + * Translate the supported ts-node options before the shared runner scans Node options. + * ts-node merges environment < inherited config < CLI/API, then typeCheck overrides + * transpileOnly. Keep all other compiler behavior for manual migration. + */ +export function prepareTsNodeRun( + args: readonly string[], + context: TsNodeRunContext, +): TsNodeRunPreparation { + const { cwd, nodeLoader, executable } = context; + if (!cwd) { + return { keepReason: KEEP_CWD }; + } + const environment: TsNodeOptions = {}; + let environmentProject: string | undefined; + for (const assignment of context.environment) { + const separator = assignment.indexOf('='); + if (separator < 0) { + continue; + } + const originalName = assignment.slice(0, separator); + const name = originalName.toUpperCase(); + const value = assignment.slice(separator + 1); + if (name.startsWith('TS_NODE_') && originalName !== name) { + return { + keepReason: `\`${originalName}\` uses platform-dependent environment variable casing`, + }; + } + if (name === 'TS_NODE_PROJECT') { + environmentProject = value || undefined; + } else if (name === 'TS_NODE_TRANSPILE_ONLY' || name === 'TS_NODE_TYPE_CHECK') { + const parsed = booleanEnvironment(value); + if (parsed === undefined) { + return { keepReason: `\`${name}\` does not have a literal boolean value` }; + } + environment[name === 'TS_NODE_TRANSPILE_ONLY' ? 'transpileOnly' : 'typeCheck'] = parsed; + } else if (name.startsWith('TS_NODE_')) { + return { + keepReason: `\`${name}\` configures ts-node behavior that vp migrate does not translate`, + }; + } else if (name === 'OXC_TSCONFIG_PATH' && value) { + return { keepReason: '`OXC_TSCONFIG_PATH` would select a different configuration for `vpx`' }; + } + } + + const explicit: TsNodeOptions = {}; + if (executable === 'ts-node-transpile-only') { + explicit.transpileOnly = true; + } else if (executable !== 'ts-node' && executable !== 'ts-node-esm' && !nodeLoader) { + return { keepReason: `vp migrate does not translate the \`${executable}\` execution mode` }; + } + const edits: NonNullable = []; + let project: string | undefined; + let projectIndex: number | undefined; + let projectInline = false; + let entry: string | undefined; + let loaders = 0; + for (let index = 0; index < args.length; index++) { + const value = args[index]; + if (value === '--') { + entry = args[index + 1]; + break; + } + if (!value.startsWith('-')) { + entry = value; + break; + } + const equals = value.indexOf('='); + const originalOption = equals < 0 ? value : value.slice(0, equals); + const name = + nodeLoader && originalOption.startsWith('--') + ? originalOption.replaceAll('_', '-') + : originalOption; + const inline = equals < 0 ? undefined : value.slice(equals + 1); + if (nodeLoader && (name === '--env-file' || name === '--env-file-if-exists')) { + return { + keepReason: `\`${name}\` can configure ts-node through environment variables; vp migrate does not translate those settings`, + }; + } + if ( + nodeLoader && + [ + '--config-file', + '--experimental-config-file', + '--experimental-default-config-file', + ].includes(name) + ) { + return { + keepReason: `\`${name}\` can load preloads that configure ts-node; migrate it manually`, + }; + } + const booleanOption = BOOLEAN_OPTIONS.get(name); + if (!nodeLoader && booleanOption) { + if (inline !== undefined) { + return { + keepReason: `ts-node treats \`${name}=value\` as an enabled flag; use an explicit configuration value before migrating`, + }; + } + explicit[booleanOption] = true; + edits.push({ index }); + continue; + } + if (!nodeLoader && (name === '-P' || name === '--project')) { + if (projectIndex !== undefined) { + return { keepReason: 'the command selects a ts-node project more than once' }; + } + project = inline ?? args[index + 1]; + projectIndex = index; + projectInline = inline !== undefined; + if (!project || project.startsWith('-')) { + return { keepReason: `\`${name}\` does not name a tsconfig` }; + } + if (!projectInline) { + index++; + } + continue; + } + if (PRELOAD_OPTIONS.has(name)) { + if (!nodeLoader && name !== '-r' && name !== '--require') { + return { + keepReason: `\`${name}\` uses ts-node behavior that vp migrate does not translate`, + }; + } + const preload = inline ?? args[index + 1]; + if (!preload || preload.startsWith('-')) { + return { keepReason: `\`${name}\` does not name a preload` }; + } + if (TS_NODE_LOADER.test(preload)) { + if (!nodeLoader || ++loaders > 1) { + return { keepReason: 'the command registers ts-node more than once' }; + } + if (/\/transpile-only(?:\.mjs)?$/.test(preload)) { + explicit.transpileOnly = true; + } else if (preload.endsWith('/type-check')) { + explicit.typeCheck = true; + } + } else { + return { + keepReason: `the additional preload \`${preload}\` can configure ts-node before execution; migrate it manually`, + }; + } + if (inline === undefined) { + index++; + } + continue; + } + if (!nodeLoader) { + return { keepReason: `\`${name}\` uses ts-node behavior that vp migrate does not translate` }; + } + if (inline === undefined && NODE_OPTIONS_WITH_VALUE.has(name)) { + index++; + } + } + if (!entry || entry === '-') { + return { keepReason: 'vp migrate only replaces ts-node commands that name a script file' }; + } + + const selectedProject = project ?? environmentProject; + if (context.shebang && selectedProject !== undefined) { + return { + keepReason: + 'the ts-node shebang selects a tsconfig relative to the caller; migrate it manually', + }; + } + let configFile: string | undefined; + let configArgument = project; + if (selectedProject) { + configFile = path.resolve(cwd, selectedProject); + if (fs.statSync(configFile, { throwIfNoEntry: false })?.isDirectory()) { + if (project === undefined) { + return { + keepReason: '`TS_NODE_PROJECT` names a directory; `vpx` requires the tsconfig file path', + }; + } + configFile = path.join(configFile, 'tsconfig.json'); + configArgument = path.join(project, 'tsconfig.json'); + } + } else { + let searchDir = cwd; + if (!nodeLoader) { + try { + searchDir = path.dirname(fs.realpathSync(path.resolve(cwd, entry))); + } catch { + return { + keepReason: `\`${entry}\` cannot be resolved, so its ts-node configuration cannot be checked`, + }; + } + } + configFile = nearestTsconfig(searchDir); + } + const config = configFile ? readTsNodeOptions(configFile) : { options: {} }; + if (config.keepReason) { + return { keepReason: config.keepReason }; + } + if (context.shebang && configFile) { + return { + keepReason: + 'the ts-node shebang needs a pinned tsconfig, whose path depends on the caller; migrate it manually', + }; + } + const options = { ...environment, ...config.options, ...explicit }; + if (options.transpileOnly !== true || options.typeCheck === true) { + return { keepReason: KEEP_TYPE_CHECK }; + } + if (projectIndex !== undefined) { + edits.push({ + index: projectIndex, + value: `--tsconfig=${configArgument!.split(path.sep).join('/')}`, + }); + if (!projectInline) { + edits.push({ index: projectIndex + 1 }); + } + } + if (configFile && selectedProject === undefined) { + // ts-node applies its selected config to the whole run; vpx otherwise discovers + // configs per file. Pin the same file with a portable path from the command's cwd. + const relative = path + .relative(fs.realpathSync(cwd), fs.realpathSync(configFile)) + .split(path.sep) + .join('/'); + return { edits, prependArgs: [`--tsconfig=${relative}`] }; + } + return { edits }; +} diff --git a/packages/cli/src/migration/migrator/ts-node.ts b/packages/cli/src/migration/migrator/ts-node.ts new file mode 100644 index 0000000000..555c56325e --- /dev/null +++ b/packages/cli/src/migration/migrator/ts-node.ts @@ -0,0 +1,140 @@ +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 { + detectScriptRunnerProject, + migrateScriptRunnerToVpx, + prepareTsNodeRun, + rewriteScriptRunnerCommand, + type ScriptRunnerCommandRewrite, + type ScriptRunnerMigration, + typescriptScriptKeepReason, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; + +// Include unsupported entrypoints and package subpaths in retention scans. +const TS_NODE_REFERENCE = + /(?:^|[\s;&|("'=/\\`])ts-node(?:-(?:cwd|esm|script|transpile-only))?(?:\.(?:cmd|exe|ps1))?(?=$|[@/\s;&|)"'`])/i; +const TS_NODE_LOADERS = /^ts-node(?:\/.*)?$/; +const TS_NODE_RUNNER: ScriptRunnerMigration = { + name: 'ts-node', + aliases: ['ts-node-esm', 'ts-node-transpile-only'], + watchSubcommand: false, + dependencies: ['ts-node'], + commandPattern: TS_NODE_REFERENCE, + loaderPattern: TS_NODE_LOADERS, + isLoader(option, value) { + return ( + ((option === '-r' || option === '--require' || option === '--import') && + /^ts-node\/register(?:\/(?:transpile-only|type-check|files))?$/.test(value)) || + ((option === '--loader' || option === '--experimental-loader') && + /^ts-node\/esm(?:\/transpile-only)?(?:\.mjs)?$/.test(value)) + ); + }, + envShebang: + /^(#!\s*\S*\benv\s+(?:-S\s+)?)(ts-node(?:-(?:esm|transpile-only))?(?=[ \t\r\n]|$)[^\r\n]*)/, + shebangPattern: /^#![^\n]*[\s/]ts-node(?:-(?:cwd|esm|script|transpile-only))?(?=\s|$)/, + codeReference: TS_NODE_REFERENCE, + textReference: TS_NODE_REFERENCE, + configValue: TS_NODE_REFERENCE, + referenceFileNamePattern: /^\.env(?:\.|$)/, + keepOptions: new Map([ + ['-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'], + ['--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', 'Node.js watch mode and `vpx --watch` use different watchers'], + ['--watch-path', 'Node.js watch mode and `vpx --watch` use different watchers'], + ['-h', 'ts-node and `vpx` have different help output'], + ['--help', 'ts-node and `vpx` have different help output'], + ['-v', 'ts-node and `vpx` have different versions'], + ['--version', 'ts-node and `vpx` have different versions'], + ]), + optionsWithValue: new Set(['--tsconfig']), + testWithoutFilesReason: 'list the test files explicitly before migrating to `vpx --test`', + envKeepReason(assignments) { + if ( + assignments.some( + (assignment) => + /^NODE_OPTIONS=/i.test(assignment) && TS_NODE_REFERENCE.test(assignment.slice(13)), + ) + ) { + return '`NODE_OPTIONS` loads ts-node hooks that would compete with those of `vpx`; remove it first'; + } + if (assignments.some((assignment) => /^NODE_OPTIONS=.+/i.test(assignment))) { + return '`NODE_OPTIONS` can change ts-node startup or load configuration preloads; review it before migrating'; + } + return undefined; + }, + prepareRun: (args, context) => prepareTsNodeRun(args, context), + scriptKeepReason(cwd, script) { + if (cwd !== undefined && !/[*?[{]/.test(script)) { + try { + const file = script.startsWith('~/') + ? path.join(os.homedir(), script.slice(2)) + : path.resolve(cwd, script); + const source = fs.readFileSync(file, 'utf8').replace(/^#![^\n]*\n?/, ''); + if (TS_NODE_REFERENCE.test(source)) { + return `\`${script}\` uses a standalone ts-node API or loader that does not configure the bundled \`vpx\` loader`; + } + } catch { + // The shared source check also reports generated entries that do not exist yet. + } + } + return typescriptScriptKeepReason(cwd, script, 'ts-node'); + }, + detectKeptUses: true, +}; + +export function rewriteTsNodeCommand(command: string, cwd?: string): ScriptRunnerCommandRewrite { + return rewriteScriptRunnerCommand(TS_NODE_RUNNER, command, cwd); +} + +export function detectTsNodeProject( + rootDir: string, + packages?: readonly WorkspacePackage[], +): boolean { + return detectScriptRunnerProject(TS_NODE_RUNNER, rootDir, packages); +} + +export function migrateTsNodeToVpx( + rootDir: string, + packages?: readonly WorkspacePackage[], + report?: MigrationReport, +): { changed: boolean; dependencyRemoved: boolean } { + const result = migrateScriptRunnerToVpx(TS_NODE_RUNNER, rootDir, packages, report); + if (report && result.scriptsChanged) { + report.tsNodeMigrated = true; + } + return { changed: result.changed, dependencyRemoved: result.dependencyRemoved }; +} + +export async function confirmTsNodeMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate ts-node scripts to vpx?\n ' + + styleText( + 'gray', + 'Commands with type-checking disabled become `vpx`. Commands that type-check or use custom ts-node settings stay unchanged. The ts-node devDependency is removed once nothing uses it.', + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + prompts.log.info('ts-node scripts detected. Auto-migrating supported commands to vpx...'); + return true; +} diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index b37b9941b3..b977044e12 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -133,7 +133,7 @@ const TSX_RUNNER: ScriptRunnerMigration = { optionsWithValue: new Set([...WATCH_OPTIONS_WITH_VALUE, '--tsconfig']), testWithoutFilesReason: KEEP_TEST_GLOBS, envKeepReason, - scriptKeepReason, + scriptKeepReason: (cwd, script) => typescriptScriptKeepReason(cwd, script, 'tsx'), }; export type TsxCommandRewrite = ScriptRunnerCommandRewrite; @@ -178,7 +178,11 @@ function envKeepReason(assignments: readonly string[]): string | undefined { * Node.js does, rejects ES module syntax in a `.cts` file, and runs the other files as ES * modules, so `__dirname`, `__filename`, and `require` are not defined. */ -function scriptKeepReason(cwd: string | undefined, script: string): string | undefined { +export function typescriptScriptKeepReason( + cwd: string | undefined, + script: string, + runner: string, +): string | undefined { // A glob is not checked: its files can differ when the command runs. if ( cwd === undefined || @@ -199,13 +203,13 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und ? `\`${script}\` does not exist yet, so vp migrate cannot check it` : undefined; } - for (const [, base, extension] of source.matchAll(RELATIVE_REQUIRE)) { + for (const [, base, extension] of runner === 'tsx' ? source.matchAll(RELATIVE_REQUIRE) : []) { const resolve = (ext: string) => path.resolve(path.dirname(file), `${base}.${ext}`); const typescript = fs.existsSync(resolve(extension)) ? undefined : TYPESCRIPT_EXTENSIONS[extension].find((ext) => fs.existsSync(resolve(ext))); if (typescript) { - return `\`${script}\` requires \`${base}.${extension}\`, which only tsx resolves to \`${base}.${typescript}\`; write the TypeScript extension`; + return `\`${script}\` requires \`${base}.${extension}\`, which only ${runner} resolves to \`${base}.${typescript}\`; write the TypeScript extension`; } } // A comment can start a line before ES module syntax, as in `/* banner */ import ...`. diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index c0cb3ef0fc..ab9d75722f 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -27,6 +27,7 @@ export interface MigrationReport { tsupMigrated: boolean; tsxMigrated: boolean; oxnodeMigrated: boolean; + tsNodeMigrated: boolean; nodeVersionFileMigrated: boolean; setupVpVersionUpdatedFileCount: number; gitHooksConfigured: boolean; @@ -54,6 +55,7 @@ export function createMigrationReport(): MigrationReport { tsupMigrated: false, tsxMigrated: false, oxnodeMigrated: false, + tsNodeMigrated: false, prettierMigrated: false, nodeVersionFileMigrated: false, setupVpVersionUpdatedFileCount: 0, diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 5a8c66f5c4..74a2c59daa 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -370,7 +370,7 @@ Package mode through the global `vpx` is unchanged. What changes: ## Future Enhancements -- **`vp migrate` rules for other runners**: `ts-node` (which type-checks by default), `esno`, and `jiti` need their own rules. +- **`vp migrate` rules for other runners**: `esno` and `jiti` remain unsupported. Migration handles compatible `tsx`, `oxnode`, and transpile-only `ts-node` commands; see the [migration rules](../docs/guide/migrate-rules.md#package-script-rewrite-rules). - **TypeScript eval and REPL**, once oxc-node transforms eval input. - **Opt-in transform cache**, keyed like tsx's. - **Programmatic API**: a `tsImport()` equivalent exported from `vite-plus`. From a7757054c291706e8a79a25e682b72329bb746ef Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 17:49:07 +0900 Subject: [PATCH 2/6] test(migrate): expect portable tsconfig paths on Windows --- packages/cli/src/migration/__tests__/ts-node-config.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/migration/__tests__/ts-node-config.spec.ts b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts index c84de269af..5e2333e7b6 100644 --- a/packages/cli/src/migration/__tests__/ts-node-config.spec.ts +++ b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts @@ -137,10 +137,7 @@ describe('prepareTsNodeRun', () => { it('maps project directories and gives CLI project selection precedence over the environment', () => { config({ 'ts-node': { transpileOnly: true } }, 'scripts/tsconfig.json'); expect(prepare(['-P', 'scripts', 'scripts/a.ts'], ['TS_NODE_PROJECT=missing.json'])).toEqual({ - edits: [ - { index: 0, value: `--tsconfig=${path.join('scripts', 'tsconfig.json')}` }, - { index: 1 }, - ], + edits: [{ index: 0, value: '--tsconfig=scripts/tsconfig.json' }, { index: 1 }], }); expect(prepare(['scripts/a.ts'], ['TS_NODE_PROJECT=scripts/tsconfig.json'])).toEqual({ edits: [], From ee2810e503310deeb33f3fbb305afd57fd1eb7e7 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 22:49:42 +0900 Subject: [PATCH 3/6] feat(migrate): preserve Node.js for ts-node register hooks --- .../__tests__/ts-node-config.spec.ts | 62 ++++++++ .../src/migration/__tests__/ts-node.spec.ts | 135 ++++++++++++++++-- .../src/migration/migrator/script-runner.ts | 39 ++++- .../src/migration/migrator/ts-node-config.ts | 45 +++++- .../cli/src/migration/migrator/ts-node.ts | 25 ++-- 5 files changed, 276 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/migration/__tests__/ts-node-config.spec.ts b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts index 5e2333e7b6..628b5c10e3 100644 --- a/packages/cli/src/migration/__tests__/ts-node-config.spec.ts +++ b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts @@ -299,4 +299,66 @@ describe('prepareTsNodeRun', () => { expect(prepare(['-T', entry]).keepReason).toContain('cannot be resolved'); } }); + + it('validates literal NODE_OPTIONS loader arguments before preserving Node.js', () => { + const context = { + cwd, + environment: ['NODE_OPTIONS=--require ts-node/register/transpile-only'], + executable: 'node', + nodeLoader: true, + publicRegister: true, + nodeOptionsArgs: ['--require', 'ts-node/register/transpile-only'], + }; + expect(prepareTsNodeRun(['scripts/a.ts'], context)).toEqual({ edits: [] }); + expect( + prepareTsNodeRun(['scripts/a.ts'], { ...context, nodeOptionsArgs: undefined }).keepReason, + ).toContain('cannot be checked'); + expect( + prepareTsNodeRun(['scripts/a.ts'], { + ...context, + nodeOptionsArgs: ['-r', 'ts-node/register'], + }).keepReason, + ).toContain('type-checks'); + expect( + prepareTsNodeRun(['scripts/a.ts'], { + ...context, + nodeOptionsArgs: [...context.nodeOptionsArgs, '-r', 'dotenv/config'], + }).keepReason, + ).toContain('additional preload'); + }); + + it('does not pass an implicit tsconfig pin to Node.js', () => { + config({ 'ts-node': { transpileOnly: true } }); + const context = { + cwd, + environment: [], + executable: 'node', + nodeLoader: true, + publicRegister: true, + }; + expect( + prepareTsNodeRun(['-r', 'ts-node/register', 'scripts/a.ts'], context).keepReason, + ).toContain('set TS_NODE_PROJECT'); + expect( + prepareTsNodeRun(['-r', 'ts-node/register', 'scripts/a.ts'], { + ...context, + environment: ['TS_NODE_PROJECT=tsconfig.json'], + }), + ).toEqual({ edits: [] }); + }); + + it.each(['VP_SCRIPT_TSCONFIG', 'TSX_TSCONFIG_PATH', 'OXC_TSCONFIG_PATH'])( + 'retains %s because the public preload gives it different precedence', + (name) => { + expect( + prepareTsNodeRun(['-r', 'ts-node/register/transpile-only', 'scripts/a.ts'], { + cwd, + environment: [`${name}=other.json`], + executable: 'node', + nodeLoader: true, + publicRegister: true, + }).keepReason, + ).toContain('different configuration'); + }, + ); }); diff --git a/packages/cli/src/migration/__tests__/ts-node.spec.ts b/packages/cli/src/migration/__tests__/ts-node.spec.ts index a62fe7aff3..2fe93aabc2 100644 --- a/packages/cli/src/migration/__tests__/ts-node.spec.ts +++ b/packages/cli/src/migration/__tests__/ts-node.spec.ts @@ -33,6 +33,7 @@ describe('ts-node migration', () => { write(file, 'export {};\n'); } write('a.cts', 'exports.value = 1;\n'); + write('setup.cjs', "process.env.TS_NODE_TYPE_CHECK = 'true';\n"); }); afterEach(() => { @@ -51,11 +52,48 @@ describe('ts-node migration', () => { ['npm exec -- ts-node -T a.ts', 'vpx a.ts'], ['yarn ts-node -T a.ts', 'vpx a.ts'], ['bunx ts-node -T a.ts', 'vpx a.ts'], - ['node -r ts-node/register/transpile-only a.ts', 'vpx a.ts'], - ['node --require=ts-node/register/transpile-only a.cts', 'vpx a.cts'], - ['node --loader ts-node/esm/transpile-only a.ts', 'vpx a.ts'], - ['node --loader=ts-node/esm/transpile-only.mjs --inspect a.ts', 'vpx --inspect a.ts'], - ['node -C dev -r ts-node/register/transpile-only a.ts', 'vpx -C dev a.ts'], + ['node -r ts-node/register/transpile-only a.ts', 'node --import vite-plus/register a.ts'], + [ + 'node --require=ts-node/register/transpile-only a.cts', + 'node --import=vite-plus/register a.cts', + ], + ['node --loader ts-node/esm/transpile-only a.ts', 'node --import vite-plus/register a.ts'], + [ + 'node --loader=ts-node/esm/transpile-only.mjs --inspect a.ts', + 'node --import=vite-plus/register --inspect a.ts', + ], + [ + 'node -C dev -r ts-node/register/transpile-only a.ts', + 'node -C dev --import vite-plus/register a.ts', + ], + [ + 'node --experimental-loader ts-node/esm/transpile-only --watch a.ts', + 'node --import vite-plus/register --watch a.ts', + ], + [ + 'node --import ts-node/register/transpile-only --test a.ts', + 'node --import vite-plus/register --test a.ts', + ], + [ + 'TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register a.ts', + 'TS_NODE_TRANSPILE_ONLY=1 node --import vite-plus/register a.ts', + ], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only" node a.ts', + 'NODE_OPTIONS="--import vite-plus/register" node a.ts', + ], + [ + 'NODE_OPTIONS=--loader=ts-node/esm/transpile-only node a.ts', + 'NODE_OPTIONS=--import=vite-plus/register node a.ts', + ], + [ + 'NODE_OPTIONS="--trace-warnings" node -r ts-node/register/transpile-only a.ts', + 'NODE_OPTIONS="--trace-warnings" node --import vite-plus/register a.ts', + ], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only" concurrently "node a.ts" "node b.ts"', + 'NODE_OPTIONS="--import vite-plus/register" concurrently "node a.ts" "node b.ts"', + ], [ 'cross-env TS_NODE_TRANSPILE_ONLY=true ts-node a.ts', 'cross-env TS_NODE_TRANSPILE_ONLY=true vpx a.ts', @@ -89,13 +127,12 @@ describe('ts-node migration', () => { ['ts-node -T watch a.ts', 'cannot be resolved'], ['ts-node -T --eval 1', 'does not translate'], ['ts-node -T', 'name a script'], - ['node -r ts-node/register/transpile-only --watch a.ts', 'different watchers'], ['node -r ts-node/register/transpile-only --env-file=.env a.ts', 'can configure ts-node'], ['node --env_file=.env -r ts-node/register/transpile-only a.ts', 'can configure ts-node'], ['node -r dotenv/config -r ts-node/register/transpile-only a.ts', 'additional preload'], [ 'NODE_OPTIONS="-r ./setup.cjs" node -r ts-node/register/transpile-only a.ts', - 'configuration preloads', + 'additional preload', ], ['NODE_OPTIONS="-r dotenv/config" ts-node -T a.ts', 'configuration preloads'], ['node -r ts-node/register/transpile-only --import ./setup.ts a.ts', 'additional preload'], @@ -108,6 +145,48 @@ describe('ts-node migration', () => { ['ts-node-cwd -T a.ts', 'does not rewrite'], ['ts-node -T a.ts | tee output', 'shell syntax'], ['ts-node -T $ENTRY', 'shell syntax'], + ['NODE_OPTIONS="--require ts-node/register" node a.ts', 'type-checks'], + [ + 'TS_NODE_TYPE_CHECK=1 NODE_OPTIONS="--require ts-node/register/transpile-only" node a.ts', + 'type-checks', + ], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only" node -r ts-node/register/transpile-only a.ts', + 'more than once', + ], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only -r dotenv/config" node a.ts', + 'additional preload', + ], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only" node --env-file=.env a.ts', + 'can configure ts-node', + ], + [ + 'TSX_TSCONFIG_PATH=other.json node -r ts-node/register/transpile-only a.ts', + 'different configuration', + ], + [ + 'VP_SCRIPT_TSCONFIG=other.json node -r ts-node/register/transpile-only a.ts', + 'different configuration', + ], + [ + 'OXC_TSCONFIG_PATH=other.json node -r ts-node/register/transpile-only a.ts', + 'different configuration', + ], + ['node -r ts-node/register/transpile-only --test', 'name a script'], + [ + 'node -r ts-node/register/transpile-only -e "require(\'ts-node\').register({ typeCheck: true })" a.ts', + 'inline code or REPL', + ], + ['node -r ts-node/register/transpile-only --print=1 a.ts', 'inline code or REPL'], + ['node -r ts-node/register/transpile-only -pe1 a.ts', 'inline code or REPL'], + ['node -r ts-node/register/transpile-only --interactive a.ts', 'inline code or REPL'], + ['NODE_OPTIONS="--require ts-node/register/transpile-only" mocha', 'literal node command'], + [ + 'NODE_OPTIONS="--require ts-node/register" concurrently "node a.ts" "npm run test"', + 'type-checks', + ], ['cd scripts && ts-node -T a.ts', 'working directory is unknown'], ])('retains %s', (command, reason) => { expect(rewriteTsNodeCommand(command, root)).toEqual({ @@ -116,7 +195,7 @@ describe('ts-node migration', () => { }); }); - it('pins the config selected by the CLI or Node loader without overlapping loader edits', () => { + it('pins the CLI config and retains Node loaders that need an implicit config pin', () => { write('tsconfig.json', { 'ts-node': { transpileOnly: true } }); write('scripts/tsconfig.json', { 'ts-node': { transpileOnly: true }, @@ -130,13 +209,49 @@ describe('ts-node migration', () => { '--require ts-node/register', '--require=ts-node/register/transpile-only', ]) { - expect(rewriteTsNodeCommand(`node ${loader} scripts/a.ts`, root)).toEqual({ - command: 'vpx --tsconfig=tsconfig.json scripts/a.ts', + const command = `node ${loader} scripts/a.ts`; + expect(rewriteTsNodeCommand(command, root)).toEqual({ + command, + kept: [expect.stringContaining('set TS_NODE_PROJECT')], + }); + } + }); + + it('keeps explicit TS_NODE_PROJECT files for Node loaders and literal NODE_OPTIONS', () => { + write('tsconfig.json', { 'ts-node': { typeCheck: true } }); + write('configs/runner.json', { 'ts-node': { transpileOnly: true } }); + for (const [before, after] of [ + ['node -r ts-node/register a.ts', 'node --import vite-plus/register a.ts'], + [ + 'NODE_OPTIONS="--require ts-node/register" node a.ts', + 'NODE_OPTIONS="--import vite-plus/register" node a.ts', + ], + ]) { + const prefix = 'TS_NODE_PROJECT=configs/runner.json '; + expect(rewriteTsNodeCommand(prefix + before, root)).toEqual({ + command: prefix + after, kept: [], }); } }); + it('removes ts-node when all Node.js loader and NODE_OPTIONS uses migrate', () => { + write('package.json', { + scripts: { + seed: 'node -r ts-node/register/transpile-only a.ts', + test: 'NODE_OPTIONS="--loader=ts-node/esm/transpile-only" node --test b.ts', + }, + devDependencies: { 'ts-node': '^10.9.2' }, + }); + expect(migrateTsNodeToVpx(root)).toEqual({ changed: true, dependencyRemoved: true }); + expect(readJson('package.json').scripts).toEqual({ + seed: 'node --import vite-plus/register a.ts', + test: 'NODE_OPTIONS="--import=vite-plus/register" node --test b.ts', + }); + expect(readJson('package.json').devDependencies).toEqual({}); + expect(migrateTsNodeToVpx(root)).toEqual({ changed: false, dependencyRemoved: false }); + }); + it('translates explicit project paths with spaces using portable quotes', () => { write('config files/tsconfig.json', { 'ts-node': { transpileOnly: true } }); expect(rewriteTsNodeCommand('ts-node -P "config files" a.ts', root)).toEqual({ diff --git a/packages/cli/src/migration/migrator/script-runner.ts b/packages/cli/src/migration/migrator/script-runner.ts index ee8301975b..47d30c293f 100644 --- a/packages/cli/src/migration/migrator/script-runner.ts +++ b/packages/cli/src/migration/migrator/script-runner.ts @@ -48,6 +48,8 @@ export interface ScriptRunnerMigration { registerKeepOptions?: ReadonlyMap; registerPreloadKeepReason?: (option: string, value: string) => string | undefined; registerInlineCodeKeepReason?: (code: string) => string | undefined; + /** Require a literal Node.js invocation when configuration preflight depends on its argv. */ + registerEnvRequiresNode?: boolean; prepareRun?: ( args: readonly string[], context: { @@ -56,6 +58,9 @@ export interface ScriptRunnerMigration { nodeLoader: boolean; executable: string; shebang?: boolean; + publicRegister?: boolean; + /** Original literal NODE_OPTIONS arguments, or undefined when they cannot be parsed. */ + nodeOptionsArgs?: readonly string[]; }, ) => { keepReason?: string; @@ -260,6 +265,10 @@ function rewriteSegment( } } else if (values.slice(start).some((value) => runner.commandPattern.test(value))) { rewrite.kept.push(`vp migrate does not rewrite this use of ${runner.name}`); + } else if (environment.replacements.length && runner.registerEnvRequiresNode) { + rewrite.kept.push( + `vp migrate only replaces ${runner.name} in NODE_OPTIONS for a literal node command`, + ); } if (environment.replacements.length) { // All children inherit this assignment. Keep it and their edits together if one @@ -321,6 +330,7 @@ function literalEdit(command: string, token: LiteralCommandToken, value: string) interface EnvironmentOptions { replacements: { index: number; value: string }[]; preloads: string[]; + args?: string[]; keepReason?: string; } @@ -329,7 +339,7 @@ function scanEnvironmentOptions( runner: ScriptRunnerMigration, assignments: readonly string[], ): EnvironmentOptions { - const result: EnvironmentOptions = { replacements: [], preloads: [] }; + const result: EnvironmentOptions = { replacements: [], preloads: [], args: [] }; for (const [index, assignment] of assignments.entries()) { if (!/^NODE_OPTIONS=/i.test(assignment)) { continue; @@ -338,6 +348,7 @@ function scanEnvironmentOptions( const shellOnlySyntax = value.includes("'") || value.replaceAll(/"[^"]*"/g, '').includes('\t'); const segments = shellOnlySyntax ? undefined : parseLiteralCommands(value, false); if (!segments || segments.length !== 1) { + result.args = undefined; if (runner.commandPattern.test(value)) { result.keepReason = runner.envKeepReason([assignment]) ?? @@ -345,6 +356,7 @@ function scanEnvironmentOptions( } continue; } + result.args?.push(...segments[0].map(({ value }) => value)); const scanned = scanOptions(runner, segments[0], 0, false); result.preloads.push(...scanned.preloads); result.keepReason ??= scanned.registerKeepReason; @@ -463,7 +475,15 @@ function rewriteNodeLoader( original.loaders === original.imports.length && (original.imports.length || environmentOptions.replacements.length) ) { - const prepared = prepareRun(runner, rewrite, tokens, start, environment, true); + const prepared = prepareRun( + runner, + rewrite, + tokens, + start, + environment, + true, + environmentOptions, + ); 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 @@ -533,6 +553,7 @@ function prepareRun( start: number, environment: readonly string[], nodeLoader: boolean, + publicRegister?: EnvironmentOptions, ): { tokens: LiteralCommandToken[]; edits: Edit[]; keepReason?: string } { const prepared = runner.prepareRun?.( tokens.slice(start + 1).map(({ value }) => value), @@ -541,6 +562,8 @@ function prepareRun( environment, nodeLoader, executable: tokens[start].value, + publicRegister: publicRegister !== undefined, + nodeOptionsArgs: publicRegister?.args, }, ); const edits: Edit[] = []; @@ -608,7 +631,7 @@ interface ScannedOptions { removals: Edit[]; loaders: number; runnerLoader: boolean; - imports: { token: LiteralCommandToken; value: string }[]; + imports: { token: LiteralCommandToken; value: string; option?: LiteralCommandToken }[]; registerKeepReason?: string; inlineCode: boolean; /** Local `--require` and `--import` modules, which load before the scripts. */ @@ -619,7 +642,10 @@ interface ScannedOptions { } function importEdits(command: string, scanned: ScannedOptions): Edit[] { - return scanned.imports.map(({ token, value }) => literalEdit(command, token, value)); + return scanned.imports.flatMap(({ token, value, option }) => [ + ...(option ? [literalEdit(command, option, '--import')] : []), + literalEdit(command, token, value), + ]); } /** Read options from the executable to the entry file. */ @@ -674,9 +700,8 @@ function scanOptions( 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', + value: inline ? '--import=vite-plus/register' : 'vite-plus/register', + option: !inline && name !== '--import' ? tokens[index] : undefined, }); } } else if (loader !== undefined) { diff --git a/packages/cli/src/migration/migrator/ts-node-config.ts b/packages/cli/src/migration/migrator/ts-node-config.ts index 8ac7666470..7d70974c21 100644 --- a/packages/cli/src/migration/migrator/ts-node-config.ts +++ b/packages/cli/src/migration/migrator/ts-node-config.ts @@ -12,6 +12,8 @@ interface TsNodeRunContext { nodeLoader: boolean; executable: string; shebang?: boolean; + publicRegister?: boolean; + nodeOptionsArgs?: readonly string[]; } interface TsNodeRunPreparation { @@ -146,6 +148,17 @@ export function prepareTsNodeRun( if (!cwd) { return { keepReason: KEEP_CWD }; } + if (context.publicRegister) { + if ( + context.nodeOptionsArgs === undefined && + context.environment.some((assignment) => /^NODE_OPTIONS=.+/i.test(assignment)) + ) { + return { + keepReason: 'the literal NODE_OPTIONS arguments cannot be checked for ts-node settings', + }; + } + args = [...(context.nodeOptionsArgs ?? []), ...args]; + } const environment: TsNodeOptions = {}; let environmentProject: string | undefined; for (const assignment of context.environment) { @@ -156,6 +169,11 @@ export function prepareTsNodeRun( const originalName = assignment.slice(0, separator); const name = originalName.toUpperCase(); const value = assignment.slice(separator + 1); + if (context.publicRegister && name === 'NODE_OPTIONS' && originalName !== name) { + return { + keepReason: `\`${originalName}\` uses platform-dependent environment variable casing`, + }; + } if (name.startsWith('TS_NODE_') && originalName !== name) { return { keepReason: `\`${originalName}\` uses platform-dependent environment variable casing`, @@ -173,8 +191,13 @@ export function prepareTsNodeRun( return { keepReason: `\`${name}\` configures ts-node behavior that vp migrate does not translate`, }; - } else if (name === 'OXC_TSCONFIG_PATH' && value) { - return { keepReason: '`OXC_TSCONFIG_PATH` would select a different configuration for `vpx`' }; + } else if ( + ['OXC_TSCONFIG_PATH', 'TSX_TSCONFIG_PATH', 'VP_SCRIPT_TSCONFIG'].includes(name) && + value + ) { + return { + keepReason: `\`${name}\` would select a different configuration for the Vite+ loader`, + }; } } @@ -207,6 +230,18 @@ export function prepareTsNodeRun( ? originalOption.replaceAll('_', '-') : originalOption; const inline = equals < 0 ? undefined : value.slice(equals + 1); + if ( + context.publicRegister && + (name === '--eval' || + name === '--print' || + /^-[ep]/.test(name) || + name === '-i' || + name === '--interactive') + ) { + return { + keepReason: 'vp migrate does not translate ts-node inline code or REPL execution', + }; + } if (nodeLoader && (name === '--env-file' || name === '--env-file-if-exists')) { return { keepReason: `\`${name}\` can configure ts-node through environment variables; vp migrate does not translate those settings`, @@ -347,6 +382,12 @@ export function prepareTsNodeRun( } } if (configFile && selectedProject === undefined) { + if (context.publicRegister) { + return { + keepReason: + 'the Node.js preload needs the ts-node config pinned explicitly; set TS_NODE_PROJECT to its file path before migrating', + }; + } // ts-node applies its selected config to the whole run; vpx otherwise discovers // configs per file. Pin the same file with a portable path from the command's cwd. const relative = path diff --git a/packages/cli/src/migration/migrator/ts-node.ts b/packages/cli/src/migration/migrator/ts-node.ts index 555c56325e..c39ad7cdf1 100644 --- a/packages/cli/src/migration/migrator/ts-node.ts +++ b/packages/cli/src/migration/migrator/ts-node.ts @@ -22,6 +22,14 @@ import { type MigrationReport } from '../report.ts'; const TS_NODE_REFERENCE = /(?:^|[\s;&|("'=/\\`])ts-node(?:-(?:cwd|esm|script|transpile-only))?(?:\.(?:cmd|exe|ps1))?(?=$|[@/\s;&|)"'`])/i; const TS_NODE_LOADERS = /^ts-node(?:\/.*)?$/; +function isTsNodeLoader(option: string, value: string): boolean { + return ( + ((option === '-r' || option === '--require' || option === '--import') && + /^ts-node\/register(?:\/(?:transpile-only|type-check|files))?$/.test(value)) || + ((option === '--loader' || option === '--experimental-loader') && + /^ts-node\/esm(?:\/transpile-only)?(?:\.mjs)?$/.test(value)) + ); +} const TS_NODE_RUNNER: ScriptRunnerMigration = { name: 'ts-node', aliases: ['ts-node-esm', 'ts-node-transpile-only'], @@ -29,14 +37,9 @@ const TS_NODE_RUNNER: ScriptRunnerMigration = { dependencies: ['ts-node'], commandPattern: TS_NODE_REFERENCE, loaderPattern: TS_NODE_LOADERS, - isLoader(option, value) { - return ( - ((option === '-r' || option === '--require' || option === '--import') && - /^ts-node\/register(?:\/(?:transpile-only|type-check|files))?$/.test(value)) || - ((option === '--loader' || option === '--experimental-loader') && - /^ts-node\/esm(?:\/transpile-only)?(?:\.mjs)?$/.test(value)) - ); - }, + isLoader: isTsNodeLoader, + isRegister: isTsNodeLoader, + registerEnvRequiresNode: true, envShebang: /^(#!\s*\S*\benv\s+(?:-S\s+)?)(ts-node(?:-(?:esm|transpile-only))?(?=[ \t\r\n]|$)[^\r\n]*)/, shebangPattern: /^#![^\n]*[\s/]ts-node(?:-(?:cwd|esm|script|transpile-only))?(?=\s|$)/, @@ -123,10 +126,10 @@ export async function confirmTsNodeMigration(interactive: boolean): Promise Date: Fri, 23 Oct 2026 22:52:18 +0900 Subject: [PATCH 4/6] fix(migrate): validate all inherited ts-node launcher targets --- .../src/migration/__tests__/ts-node.spec.ts | 17 ++++++++++++ .../src/migration/migrator/script-runner.ts | 26 ++++++++++++------- .../src/migration/migrator/ts-node-config.ts | 6 +++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/migration/__tests__/ts-node.spec.ts b/packages/cli/src/migration/__tests__/ts-node.spec.ts index 2fe93aabc2..3c68682e9d 100644 --- a/packages/cli/src/migration/__tests__/ts-node.spec.ts +++ b/packages/cli/src/migration/__tests__/ts-node.spec.ts @@ -94,6 +94,10 @@ describe('ts-node migration', () => { 'NODE_OPTIONS="--require ts-node/register/transpile-only" concurrently "node a.ts" "node b.ts"', 'NODE_OPTIONS="--import vite-plus/register" concurrently "node a.ts" "node b.ts"', ], + [ + 'cross-env-shell NODE_OPTIONS="--require ts-node/register/transpile-only" "node a.ts"', + 'cross-env-shell NODE_OPTIONS="--import vite-plus/register" "node a.ts"', + ], [ 'cross-env TS_NODE_TRANSPILE_ONLY=true ts-node a.ts', 'cross-env TS_NODE_TRANSPILE_ONLY=true vpx a.ts', @@ -182,7 +186,20 @@ describe('ts-node migration', () => { ['node -r ts-node/register/transpile-only --print=1 a.ts', 'inline code or REPL'], ['node -r ts-node/register/transpile-only -pe1 a.ts', 'inline code or REPL'], ['node -r ts-node/register/transpile-only --interactive a.ts', 'inline code or REPL'], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only" node --run check -- a.ts', + 'package script', + ], ['NODE_OPTIONS="--require ts-node/register/transpile-only" mocha', 'literal node command'], + ['NODE_OPTIONS="--require ts-node/register" concurrently npm:check', 'every launcher target'], + [ + 'NODE_OPTIONS="--require ts-node/register/transpile-only" concurrently "node a.ts" npm:check', + 'every launcher target', + ], + ['NODE_OPTIONS="--require ts-node/register" nodemon --exec node a.ts', 'every launcher target'], + ['cross-env-shell NODE_OPTIONS="--require ts-node/register" node a.ts', 'type-checks'], + ['cross-env-shell NODE_OPTIONS="--require ts-node/register" mocha', 'every launcher target'], + ['NODE_OPTIONS="--require ts-node/register" nodemon a.ts', 'every launcher target'], [ 'NODE_OPTIONS="--require ts-node/register" concurrently "node a.ts" "npm run test"', 'type-checks', diff --git a/packages/cli/src/migration/migrator/script-runner.ts b/packages/cli/src/migration/migrator/script-runner.ts index 47d30c293f..fb53efa766 100644 --- a/packages/cli/src/migration/migrator/script-runner.ts +++ b/packages/cli/src/migration/migrator/script-runner.ts @@ -253,16 +253,21 @@ function rewriteSegment( 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 (shell || launcher) { + const commands = shell + ? tokens.slice(start) + : tokens.slice(start + 1).filter((_, index) => launcher!(values[start + index])); + let checked = commands.length > 0; + for (const token of commands) { + if (!rewriteQuotedCommand(runner, rewrite, token, assignments)) { + checked = false; } } + if (!checked && environment.replacements.length && runner.registerEnvRequiresNode) { + rewrite.kept.push( + `vp migrate only replaces ${runner.name} in NODE_OPTIONS when every launcher target is a quoted literal node command`, + ); + } } else if (values.slice(start).some((value) => runner.commandPattern.test(value))) { rewrite.kept.push(`vp migrate does not rewrite this use of ${runner.name}`); } else if (environment.replacements.length && runner.registerEnvRequiresNode) { @@ -769,17 +774,18 @@ function rewriteQuotedCommand( rewrite: Rewrite, token: LiteralCommandToken, environment: readonly string[], -): void { +): boolean { const raw = rewrite.command.slice(token.start, token.end); const quote = raw[0]; if ((quote !== '"' && quote !== "'") || raw.length < 2 || !raw.endsWith(quote)) { - return; + return false; } 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)); } + return token.value.trim() !== ''; } /** The commands in a scripts or lint-staged record. */ diff --git a/packages/cli/src/migration/migrator/ts-node-config.ts b/packages/cli/src/migration/migrator/ts-node-config.ts index 7d70974c21..45f6b7ac7c 100644 --- a/packages/cli/src/migration/migrator/ts-node-config.ts +++ b/packages/cli/src/migration/migrator/ts-node-config.ts @@ -230,6 +230,12 @@ export function prepareTsNodeRun( ? originalOption.replaceAll('_', '-') : originalOption; const inline = equals < 0 ? undefined : value.slice(equals + 1); + if (context.publicRegister && name === '--run') { + return { + keepReason: + 'vp migrate cannot check the ts-node settings of a package script run by Node.js', + }; + } if ( context.publicRegister && (name === '--eval' || From c0d8d394c1d61c7caab2b0e51416b21138422450 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 22:55:59 +0900 Subject: [PATCH 5/6] test(migrate): snapshot ts-node register migration --- .../fixtures/migration_ts_node/package.json | 4 +++- .../fixtures/migration_ts_node/snapshots.toml | 5 +++-- .../migration_ts_node_already_vite_plus.md | 8 ++++--- .../snapshots/migration_ts_node_config.md | 16 ++++++++++---- .../snapshots/migration_ts_node_kept.md | 8 ++++--- .../snapshots/migration_ts_node_scripts.md | 21 ++++++++++++++----- docs/guide/migrate-rules.md | 19 ++++++++++------- docs/guide/vpx.md | 2 +- packages/cli/src/migration/bin.ts | 2 +- 9 files changed, 57 insertions(+), 28 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json index eef6aefd90..fe880baa81 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/package.json @@ -3,7 +3,9 @@ "type": "module", "scripts": { "seed": "ts-node --transpileOnly ./scripts/seed.ts", - "loader": "node --loader ts-node/esm/transpile-only ./scripts/seed.ts" + "loader": "node --loader ts-node/esm/transpile-only ./scripts/seed.ts", + "register": "node --require=ts-node/register/transpile-only ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--require ts-node/register/transpile-only' node ./scripts/seed.ts" }, "devDependencies": { "ts-node": "file:.fixture/ts-node", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml index 90006455c5..9774dc3cb9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots.toml @@ -3,11 +3,12 @@ name = "migration_ts_node_scripts" vp = "local" comment = "Local stub dependencies keep the runner migration independent of npm packages." steps = [ - { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "transpile-only commands, loaders, and shebangs migrate to vpx" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "transpile-only commands and shebangs use vpx; preloads keep Node.js" }, ["vpt", "print-file", "package.json"], ["vpt", "print-file", "scripts/release.ts"], { argv = ["vp", "run", "seed"], comment = "the migrated script runs TypeScript without ts-node" }, ["vp", "run", "loader"], + { argv = ["vp", "run", "register"], comment = "the CommonJS registration option becomes the public ESM preload" }, ] [[case]] @@ -26,7 +27,7 @@ steps = [ { argv = ["vpt", "rm", "scripts/release.ts"], snapshot = false }, { argv = ["vpt", "write-file", "tsconfig.json", "{\"ts-node\":{\"transpileOnly\":true}}\n"], snapshot = false }, { argv = ["vpt", "json-edit", "package.json", "scripts.seed", "ts-node ./scripts/seed.ts"], snapshot = false }, - { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "tsconfig can explicitly disable ts-node type-checking" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "the runner pins its config; Node preloads keep ts-node until TS_NODE_PROJECT names that file" }, ["vpt", "print-file", "package.json"], ["vp", "run", "seed"], ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md index 0b2661b57a..7fd06e80be 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_already_vite_plus.md @@ -27,12 +27,12 @@ VITE+ - The Unified Toolchain for the Web 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. -● ts-node scripts detected. Auto-migrating supported commands to vpx... +● ts-node scripts detected. Auto-migrating supported commands to Vite+... ◇ Updated . to Vite+ • Node pnpm • Dependencies: vite → -• ts-node scripts migrated to `vpx` +• ts-node scripts migrated to Vite+ ``` ## `vpt print-file package.json` @@ -46,7 +46,9 @@ VITE+ - The Unified Toolchain for the Web }, "name": "migration-ts-node", "scripts": { - "loader": "vpx ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts", + "loader": "node --import vite-plus/register ./scripts/seed.ts", + "register": "node --import=vite-plus/register ./scripts/seed.ts", "seed": "vpx ./scripts/seed.ts" }, "type": "module" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md index 6a46f5d4c3..42481f7cb3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_config.md @@ -12,16 +12,21 @@ ## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` -tsconfig can explicitly disable ts-node type-checking +the runner pins its config; Node preloads keep ts-node until TS_NODE_PROJECT names that file ``` VITE+ - The Unified Toolchain for the Web -● ts-node scripts detected. Auto-migrating supported commands to vpx... +● ts-node scripts detected. Auto-migrating supported commands to Vite+... ◇ Migrated . to Vite+ • Node pnpm • 1 config update applied -• ts-node scripts migrated to `vpx` +• ts-node scripts migrated to Vite+ +! Warnings: + - Kept ts-node in package.json scripts "env-loader": the Node.js preload needs the ts-node config pinned explicitly; set TS_NODE_PROJECT to its file path before migrating. + - Kept ts-node in package.json scripts "loader": the Node.js preload needs the ts-node config pinned explicitly; set TS_NODE_PROJECT to its file path before migrating. + - Kept ts-node in package.json scripts "register": the Node.js preload needs the ts-node config pinned explicitly; set TS_NODE_PROJECT to its file path before migrating. + - Kept the ts-node devDependency in package.json: `scripts` in package.json still uses ts-node. ``` ## `vpt print-file package.json` @@ -29,13 +34,16 @@ VITE+ - The Unified Toolchain for the Web ``` { "devDependencies": { + "ts-node": "file:.fixture/ts-node", "typescript": "file:.fixture/typescript", "vite": "catalog:", "vite-plus": "catalog:" }, "name": "migration-ts-node", "scripts": { - "loader": "vpx --tsconfig=tsconfig.json ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--require ts-node/register/transpile-only' node ./scripts/seed.ts", + "loader": "node --loader ts-node/esm/transpile-only ./scripts/seed.ts", + "register": "node --require=ts-node/register/transpile-only ./scripts/seed.ts", "seed": "vpx --tsconfig=tsconfig.json ./scripts/seed.ts" }, "type": "module" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md index 600ef29460..c1d15d9d0f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_kept.md @@ -10,11 +10,11 @@ commands that type-check keep ts-node and explain the difference ``` VITE+ - The Unified Toolchain for the Web -● ts-node scripts detected. Auto-migrating supported commands to vpx... +● ts-node scripts detected. Auto-migrating supported commands to Vite+... ◇ Migrated . to Vite+ • Node pnpm • 1 config update applied -• ts-node scripts migrated to `vpx` +• ts-node scripts migrated to Vite+ ! Warnings: - Kept ts-node in package.json scripts "checked": this ts-node command type-checks before execution; `vpx` does not type-check. Keep a separate type-check command before changing this script. - Kept the ts-node devDependency in package.json: `scripts` in package.json still uses ts-node. @@ -33,7 +33,9 @@ VITE+ - The Unified Toolchain for the Web "name": "migration-ts-node", "scripts": { "checked": "ts-node ./scripts/seed.ts", - "loader": "vpx ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts", + "loader": "node --import vite-plus/register ./scripts/seed.ts", + "register": "node --import=vite-plus/register ./scripts/seed.ts", "seed": "vpx ./scripts/seed.ts" }, "type": "module" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md index 7c432a35d2..baf38bd445 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ts_node/snapshots/migration_ts_node_scripts.md @@ -4,16 +4,16 @@ Local stub dependencies keep the runner migration independent of npm packages. ## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` -transpile-only commands, loaders, and shebangs migrate to vpx +transpile-only commands and shebangs use vpx; preloads keep Node.js ``` VITE+ - The Unified Toolchain for the Web -● ts-node scripts detected. Auto-migrating supported commands to vpx... +● ts-node scripts detected. Auto-migrating supported commands to Vite+... ◇ Migrated . to Vite+ • Node pnpm • 1 config update applied -• ts-node scripts migrated to `vpx` +• ts-node scripts migrated to Vite+ ``` ## `vpt print-file package.json` @@ -24,7 +24,9 @@ VITE+ - The Unified Toolchain for the Web "type": "module", "scripts": { "seed": "vpx ./scripts/seed.ts", - "loader": "vpx ./scripts/seed.ts" + "loader": "node --import vite-plus/register ./scripts/seed.ts", + "register": "node --import=vite-plus/register ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts" }, "devDependencies": { "typescript": "file:.fixture/typescript", @@ -54,6 +56,15 @@ ready ## `vp run loader` ``` -$ vpx ./scripts/seed.ts ⊘ cache disabled +$ node --import vite-plus/register ./scripts/seed.ts ⊘ cache disabled +ready +``` + +## `vp run register` + +the CommonJS registration option becomes the public ESM preload + +``` +$ node --import=vite-plus/register ./scripts/seed.ts ⊘ cache disabled ready ``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 1b37c77ad1..b614764e71 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -329,17 +329,20 @@ The `@oxc-node/cli` and `@oxc-node/core` development dependencies are removed on Migration offers to replace supported ts-node commands with `vpx` only when type-checking is explicitly disabled. Plain `ts-node` type-checks before execution; `vpx` does not. Commands that still type-check stay unchanged with a warning, even when another option enables transpile-only mode. -| Before | After | -| ------------------------------------------------------------ | -------------------------------------------------------- | -| `ts-node --transpileOnly ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | -| `ts-node -T -P tsconfig.scripts.json ./scripts/seed.ts` | `vpx --tsconfig=tsconfig.scripts.json ./scripts/seed.ts` | -| `ts-node-transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | -| `node -r ts-node/register/transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | -| `node --loader ts-node/esm/transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| Before | After | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `ts-node --transpileOnly ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| `ts-node -T -P tsconfig.scripts.json ./scripts/seed.ts` | `vpx --tsconfig=tsconfig.scripts.json ./scripts/seed.ts` | +| `ts-node-transpile-only ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| `node -r ts-node/register/transpile-only ./scripts/seed.ts` | `node --import vite-plus/register ./scripts/seed.ts` | +| `node --loader ts-node/esm/transpile-only ./scripts/seed.ts` | `node --import vite-plus/register ./scripts/seed.ts` | +| `NODE_OPTIONS='--require ts-node/register/transpile-only' node ./scripts/seed.ts` | `NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts` | The migration also reads `transpileOnly` and `typeCheck` from local JSONC tsconfig files, local `extends` chains, and literal `TS_NODE_TRANSPILE_ONLY` / `TS_NODE_TYPE_CHECK` assignments. `-P`, `--project`, and `TS_NODE_PROJECT` select the configuration. A directory passed to `-P` or `--project` resolves to its `tsconfig.json`; a directory in `TS_NODE_PROJECT` stays unchanged. When ts-node discovers a config implicitly, migration pins that file with `--tsconfig` so `vpx` uses it for the whole run. `ts-node-esm` and `--esm` are supported when type-checking is disabled. -Custom compilers, transpilers, SWC, ts-node compiler overrides, unsupported configuration inheritance, eval, and REPL usage stay unchanged. Environment files, Node.js config files, additional preloads, and nonempty `NODE_OPTIONS` assignments also keep the original command because they can change ts-node settings before it registers its loader. Migration checks named scripts for known module incompatibilities; it does not follow their imports. Migrated scripts use the [tsconfig and module rules of `vpx`](/guide/vpx#tsconfig). +Supported Node.js preload commands keep Node.js and replace both the old loader option and module with `--import vite-plus/register`. Literal `NODE_OPTIONS` assignments also migrate when they reach a known `node` command and pass the same checks. An existing `TS_NODE_PROJECT` file selection stays in place. A preload that needs an implicit config pinned stays unchanged: set `TS_NODE_PROJECT` to the config file before migration, because Node.js cannot take `--tsconfig`. + +Custom compilers, transpilers, SWC, ts-node compiler overrides, unsupported configuration inheritance, eval, and REPL usage stay unchanged. Environment files, Node.js config files, additional preloads, and unsupported `NODE_OPTIONS` assignments also keep the original command because they can change ts-node settings before registration. Nonempty `VP_SCRIPT_TSCONFIG`, `TSX_TSCONFIG_PATH`, or `OXC_TSCONFIG_PATH` assignments stay unchanged because they would select a different config. Migration checks named scripts for known module incompatibilities; it does not follow their imports. Migrated scripts use the [tsconfig and module rules of `vpx`](/guide/vpx#tsconfig). As with oxnode, migration rewrites package scripts, JSON lint-staged tasks, and supported env shebangs. Shebangs that need a pinned config stay unchanged because the config path depends on the caller's working directory. Existing Vite+ projects use `vp migrate --full`. The `ts-node` development dependency is removed only when no remaining command, loader, API import, or config reference needs it. Runtime and peer dependencies remain, and `typescript` is not removed. diff --git a/docs/guide/vpx.md b/docs/guide/vpx.md index 30a220d3a0..61a7a5a8dc 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -234,7 +234,7 @@ JSX that depends on a compiler plugin is not supported. `vpx` compiles JSX only ### Coming from ts-node -`vp migrate` can replace supported transpile-only ts-node commands with `vpx`. It keeps commands that type-check because `vpx` does not perform that check. Custom compiler settings and remaining loader or API usage can also keep `ts-node` installed. See [ts-node to `vpx`](/guide/migrate-rules#ts-node-to-vpx). +`vp migrate` can replace supported transpile-only ts-node commands with `vpx`. Supported Node.js preloads and literal `NODE_OPTIONS` assignments use `vite-plus/register`. Migration keeps commands that type-check because neither replacement performs that check. Custom compiler settings and remaining loader or API usage can also keep `ts-node` installed. See [ts-node to `vpx`](/guide/migrate-rules#ts-node-to-vpx). ## `vp exec` diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 43ade2e5b0..9a3539f43e 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -604,7 +604,7 @@ function showMigrationSummary(options: { log(`${styleText('gray', '•')} oxnode scripts migrated to Vite+`); } if (report.tsNodeMigrated) { - log(`${styleText('gray', '•')} ts-node scripts migrated to \`vpx\``); + log(`${styleText('gray', '•')} ts-node scripts migrated to Vite+`); } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); From 60d7bd8edf31bcd48dae43b1b407989ee1f2bfb6 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 22:57:44 +0900 Subject: [PATCH 6/6] fix(migrate): retain ts-node hooks with different entrypoint config --- docs/guide/migrate-rules.md | 2 ++ .../migration/__tests__/ts-node-config.spec.ts | 16 ++++++++++++++++ .../cli/src/migration/__tests__/ts-node.spec.ts | 11 +++++++++++ .../cli/src/migration/migrator/ts-node-config.ts | 16 ++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index b614764e71..ab55b78b66 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -342,6 +342,8 @@ The migration also reads `transpileOnly` and `typeCheck` from local JSONC tsconf Supported Node.js preload commands keep Node.js and replace both the old loader option and module with `--import vite-plus/register`. Literal `NODE_OPTIONS` assignments also migrate when they reach a known `node` command and pass the same checks. An existing `TS_NODE_PROJECT` file selection stays in place. A preload that needs an implicit config pinned stays unchanged: set `TS_NODE_PROJECT` to the config file before migration, because Node.js cannot take `--tsconfig`. +Preloads also stay unchanged when the working directory has no config but the entry file would discover a different one. Migration does not scan imported modules for additional config files. + Custom compilers, transpilers, SWC, ts-node compiler overrides, unsupported configuration inheritance, eval, and REPL usage stay unchanged. Environment files, Node.js config files, additional preloads, and unsupported `NODE_OPTIONS` assignments also keep the original command because they can change ts-node settings before registration. Nonempty `VP_SCRIPT_TSCONFIG`, `TSX_TSCONFIG_PATH`, or `OXC_TSCONFIG_PATH` assignments stay unchanged because they would select a different config. Migration checks named scripts for known module incompatibilities; it does not follow their imports. Migrated scripts use the [tsconfig and module rules of `vpx`](/guide/vpx#tsconfig). As with oxnode, migration rewrites package scripts, JSON lint-staged tasks, and supported env shebangs. Shebangs that need a pinned config stay unchanged because the config path depends on the caller's working directory. Existing Vite+ projects use `vp migrate --full`. The `ts-node` development dependency is removed only when no remaining command, loader, API import, or config reference needs it. Runtime and peer dependencies remain, and `typescript` is not removed. diff --git a/packages/cli/src/migration/__tests__/ts-node-config.spec.ts b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts index 628b5c10e3..affef6d4d3 100644 --- a/packages/cli/src/migration/__tests__/ts-node-config.spec.ts +++ b/packages/cli/src/migration/__tests__/ts-node-config.spec.ts @@ -347,6 +347,22 @@ describe('prepareTsNodeRun', () => { ).toEqual({ edits: [] }); }); + it('retains a public preload that would discover a new config from the real entrypoint', () => { + config({ compilerOptions: { useDefineForClassFields: true } }, 'scripts/tsconfig.json'); + fs.symlinkSync(path.join(cwd, 'scripts/a.ts'), path.join(cwd, 'linked.ts')); + for (const entry of ['scripts/a.ts', 'linked.ts']) { + expect( + prepareTsNodeRun(['-r', 'ts-node/register/transpile-only', entry], { + cwd, + environment: [], + executable: 'node', + nodeLoader: true, + publicRegister: true, + }).keepReason, + ).toContain('an entrypoint tsconfig that ts-node does not use'); + } + }); + it.each(['VP_SCRIPT_TSCONFIG', 'TSX_TSCONFIG_PATH', 'OXC_TSCONFIG_PATH'])( 'retains %s because the public preload gives it different precedence', (name) => { diff --git a/packages/cli/src/migration/__tests__/ts-node.spec.ts b/packages/cli/src/migration/__tests__/ts-node.spec.ts index 3c68682e9d..513f0688e1 100644 --- a/packages/cli/src/migration/__tests__/ts-node.spec.ts +++ b/packages/cli/src/migration/__tests__/ts-node.spec.ts @@ -252,6 +252,17 @@ describe('ts-node migration', () => { } }); + it.each([ + 'node -r ts-node/register/transpile-only scripts/a.ts', + 'NODE_OPTIONS="--require ts-node/register/transpile-only" node scripts/a.ts', + ])('retains %s when the public preload would discover a new entrypoint config', (command) => { + write('scripts/tsconfig.json', { compilerOptions: { useDefineForClassFields: true } }); + expect(rewriteTsNodeCommand(command, root)).toEqual({ + command, + kept: [expect.stringContaining('an entrypoint tsconfig that ts-node does not use')], + }); + }); + it('removes ts-node when all Node.js loader and NODE_OPTIONS uses migrate', () => { write('package.json', { scripts: { diff --git a/packages/cli/src/migration/migrator/ts-node-config.ts b/packages/cli/src/migration/migrator/ts-node-config.ts index 45f6b7ac7c..ba10cbe613 100644 --- a/packages/cli/src/migration/migrator/ts-node-config.ts +++ b/packages/cli/src/migration/migrator/ts-node-config.ts @@ -363,6 +363,22 @@ export function prepareTsNodeRun( } } configFile = nearestTsconfig(searchDir); + if (context.publicRegister && !configFile) { + let entryConfig: string | undefined; + try { + entryConfig = nearestTsconfig(path.dirname(fs.realpathSync(path.resolve(cwd, entry)))); + } catch { + return { + keepReason: `\`${entry}\` cannot be resolved, so its ts-node configuration cannot be checked`, + }; + } + if (entryConfig) { + return { + keepReason: + 'the Node.js preload would discover an entrypoint tsconfig that ts-node does not use; select the intended config with TS_NODE_PROJECT before migrating', + }; + } + } } const config = configFile ? readTsNodeOptions(configFile) : { options: {} }; if (config.keepReason) {