diff --git a/docs/errors/OXDT0005.md b/docs/errors/OXDT0005.md
new file mode 100644
index 000000000..bfd546d6a
--- /dev/null
+++ b/docs/errors/OXDT0005.md
@@ -0,0 +1,16 @@
+---
+outline: deep
+---
+# OXDT0005: Oxlint Setup Failed
+
+## Message
+> Failed to set up Oxlint: `{reason}`
+
+## Cause
+Vite DevTools could not install or initialize Oxlint in the project root.
+
+## Fix
+Check the project package manager and configuration, then try again.
+
+## Source
+- [`packages/oxc/src/node/rpc/functions/oxlint-migrate.ts`](https://github.com/vitejs/devtools/blob/main/packages/oxc/src/node/rpc/functions/oxlint-migrate.ts) — runs the installation, initialization, and migration commands.
diff --git a/docs/errors/index.md b/docs/errors/index.md
index 4c865edb8..c73587391 100644
--- a/docs/errors/index.md
+++ b/docs/errors/index.md
@@ -78,3 +78,4 @@ Emitted by `@vitejs/devtools-oxc`.
| [OXDT0002](./OXDT0002) | error | Invalid Lint Result ID |
| [OXDT0003](./OXDT0003) | error | Failed to Delete Lint Result |
| [OXDT0004](./OXDT0004) | error | Oxlint Config Inspection Failed |
+| [OXDT0005](./OXDT0005) | error | Oxlint Setup Failed |
diff --git a/packages/oxc/package.json b/packages/oxc/package.json
index bda58c743..d71649ec9 100644
--- a/packages/oxc/package.json
+++ b/packages/oxc/package.json
@@ -46,6 +46,7 @@
"cac": "catalog:deps",
"devframe": "catalog:deps",
"local-pkg": "catalog:deps",
+ "nypm": "catalog:deps",
"nostics": "catalog:deps",
"pathe": "catalog:deps",
"tinyexec": "catalog:deps"
diff --git a/packages/oxc/src/app/pages/index.vue b/packages/oxc/src/app/pages/index.vue
index 1339b645b..ab26f31f8 100644
--- a/packages/oxc/src/app/pages/index.vue
+++ b/packages/oxc/src/app/pages/index.vue
@@ -1,42 +1,63 @@
+
-
+
+
+
+
diff --git a/packages/oxc/src/app/pages/oxlint.vue b/packages/oxc/src/app/pages/oxlint.vue
index 1b37bbb45..dc16c5088 100644
--- a/packages/oxc/src/app/pages/oxlint.vue
+++ b/packages/oxc/src/app/pages/oxlint.vue
@@ -1,6 +1,15 @@
+
-
+
+
+
+
diff --git a/packages/oxc/src/app/utils/overview.ts b/packages/oxc/src/app/utils/overview.ts
new file mode 100644
index 000000000..04ebec490
--- /dev/null
+++ b/packages/oxc/src/app/utils/overview.ts
@@ -0,0 +1,18 @@
+export function createOverview() {
+ return {
+ oxlint: {
+ installed: false,
+ version: undefined,
+ latest: true,
+ npmxLink: undefined,
+ },
+ oxfmt: {
+ installed: false,
+ version: undefined,
+ latest: true,
+ npmxLink: undefined,
+ },
+ vitePlus: undefined,
+ needsOxlintMigration: false,
+ }
+}
diff --git a/packages/oxc/src/node/__tests__/oxlint-migrate.test.ts b/packages/oxc/src/node/__tests__/oxlint-migrate.test.ts
new file mode 100644
index 000000000..a4a93ef39
--- /dev/null
+++ b/packages/oxc/src/node/__tests__/oxlint-migrate.test.ts
@@ -0,0 +1,69 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { needsOxlintMigration, oxlintInstall, oxlintMigrate } from '../rpc/functions/oxlint-setup'
+
+const fixtures: string[] = []
+
+async function createFixture() {
+ const cwd = await mkdtemp(join(tmpdir(), 'oxlint-migrate-'))
+ fixtures.push(cwd)
+ return cwd
+}
+
+afterEach(async () => {
+ await Promise.all(fixtures.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+describe('needsOxlintMigration', () => {
+ it('requires a root ESLint config and no root Oxlint config', async () => {
+ const cwd = await createFixture()
+ await writeFile(join(cwd, 'eslint.config.mjs'), 'export default []')
+ expect(needsOxlintMigration(cwd)).toBe(true)
+
+ await writeFile(join(cwd, 'oxlint.config.ts'), 'export default {}')
+ expect(needsOxlintMigration(cwd)).toBe(false)
+ })
+
+ it('runs installation and migration in one terminal session', async () => {
+ const cwd = await createFixture()
+ await writeFile(join(cwd, 'eslint.config.js'), 'export default []')
+ const getResult = vi
+ .fn<() => Promise<{ exitCode: number; stderr: string }>>()
+ .mockResolvedValue({ exitCode: 0, stderr: '' })
+ const startChildProcess = vi
+ .fn<(...args: unknown[]) => Promise<{ getResult: typeof getResult }>>()
+ .mockResolvedValue({ getResult })
+ const setup = oxlintMigrate.setup!({
+ cwd,
+ terminals: { startChildProcess, sessions: new Map() },
+ } as any)
+
+ await setup.handler!()
+
+ expect(startChildProcess).toHaveBeenCalledOnce()
+ expect(startChildProcess.mock.calls[0]![0].args.at(-1)).toContain(' && ')
+ })
+
+ it('installs Oxlint as a dev dependency before initializing it', async () => {
+ const cwd = await createFixture()
+ const startChildProcess = vi
+ .fn<
+ (
+ ...args: unknown[]
+ ) => Promise<{ getResult: () => Promise<{ exitCode: number; stderr: string }> }>
+ >()
+ .mockResolvedValue({ getResult: async () => ({ exitCode: 0, stderr: '' }) })
+ const setup = oxlintInstall.setup!({
+ cwd,
+ terminals: { startChildProcess, sessions: new Map() },
+ } as any)
+
+ await setup.handler!()
+
+ expect(startChildProcess.mock.calls[0]![0].args.at(-1)).toMatch(
+ /oxlint@latest && .*oxlint --init/,
+ )
+ })
+})
diff --git a/packages/oxc/src/node/devframe.ts b/packages/oxc/src/node/devframe.ts
index 5b6f2eab1..6aaee06d3 100644
--- a/packages/oxc/src/node/devframe.ts
+++ b/packages/oxc/src/node/devframe.ts
@@ -20,7 +20,7 @@ export const OXC_DEVTOOLS_BASE = '/__devtools-oxc/'
*/
export const oxcDevframe = defineDevframe({
id: 'devtools-oxc',
- name: 'Oxc',
+ name: 'Oxc Devtools',
version,
packageName: name,
homepage,
diff --git a/packages/oxc/src/node/diagnostics.ts b/packages/oxc/src/node/diagnostics.ts
index 52aa311d9..ac232d7a5 100644
--- a/packages/oxc/src/node/diagnostics.ts
+++ b/packages/oxc/src/node/diagnostics.ts
@@ -23,5 +23,9 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({
`Failed to inspect Oxlint config "${p.configPath}": ${p.reason}`,
fix: 'Choose a supported Oxlint config inside the workspace and ensure Oxlint can read its rules.',
},
+ OXDT0005: {
+ why: (p: { reason: string }) => `Failed to set up Oxlint: ${p.reason}`,
+ fix: 'Check the project package manager and configuration, then try again.',
+ },
},
})
diff --git a/packages/oxc/src/node/rpc/functions/get-config-files.ts b/packages/oxc/src/node/rpc/functions/get-config-files.ts
index 89323a95b..9fd014384 100644
--- a/packages/oxc/src/node/rpc/functions/get-config-files.ts
+++ b/packages/oxc/src/node/rpc/functions/get-config-files.ts
@@ -5,7 +5,6 @@ export const getConfigFiles = defineOxcRpc({
name: 'devtools-oxc:get-config-files',
type: 'query',
jsonSerializable: true,
- cacheable: true,
setup: ctx => ({
handler: () => getOxcConfigFiles(ctx.cwd),
}),
diff --git a/packages/oxc/src/node/rpc/functions/overview.ts b/packages/oxc/src/node/rpc/functions/overview.ts
index 8525876db..2ace387f6 100644
--- a/packages/oxc/src/node/rpc/functions/overview.ts
+++ b/packages/oxc/src/node/rpc/functions/overview.ts
@@ -1,6 +1,7 @@
import { defineOxcRpc } from '../_define'
import { x } from 'tinyexec'
import { getVitePlusVersions, isVitePlusInstalled } from '../../utils/vite-plus'
+import { needsOxlintMigration } from './oxlint-setup'
type Package = {
installed: boolean
@@ -50,6 +51,9 @@ export const overview = defineOxcRpc({
} else {
try {
const { stdout } = await x('oxlint', ['--version'], { nodeOptions: { cwd: ctx.cwd } })
+ if (!stdout) {
+ throw new Error('Not installed')
+ }
oxlint.installed = true
oxlint.version = stdout.split(' ')[1]?.trim().replaceAll('\n', '') ?? undefined
oxlint.latest = oxlint.version === oxlintData.version
@@ -59,6 +63,9 @@ export const overview = defineOxcRpc({
}
try {
const { stdout } = await x('oxfmt', ['--version'], { nodeOptions: { cwd: ctx.cwd } })
+ if (!stdout) {
+ throw new Error('Not installed')
+ }
oxfmt.installed = true
oxfmt.version = stdout.split(' ')[1]?.trim().replaceAll('\n', '') ?? undefined
oxfmt.latest = oxfmt.version === oxfmtData.version
@@ -71,6 +78,7 @@ export const overview = defineOxcRpc({
oxlint,
oxfmt,
vitePlus: vitePlusVersions?.vitePlus,
+ needsOxlintMigration: needsOxlintMigration(ctx.cwd),
}
},
}
diff --git a/packages/oxc/src/node/rpc/functions/oxlint-setup.ts b/packages/oxc/src/node/rpc/functions/oxlint-setup.ts
new file mode 100644
index 000000000..9071722a0
--- /dev/null
+++ b/packages/oxc/src/node/rpc/functions/oxlint-setup.ts
@@ -0,0 +1,137 @@
+import type { DevToolsTerminalHost } from '@vitejs/devtools-kit'
+import type { DevframeNodeContext } from 'devframe/types'
+import { existsSync } from 'node:fs'
+import { addDependencyCommand, detectPackageManager, dlxCommand } from 'nypm'
+import { Diagnostic } from 'nostics'
+import { join } from 'pathe'
+import { x } from 'tinyexec'
+import { diagnostics } from '../../diagnostics'
+import { CONFIG_FILES } from '../../utils/config-files'
+import { defineOxcRpc } from '../_define'
+
+const eslintConfigFiles = ['eslint.config.js', 'eslint.config.mjs']
+
+type ContextWithTerminals = DevframeNodeContext & { terminals?: DevToolsTerminalHost }
+type MigrationSession = Awaited>
+
+let current: MigrationSession | undefined
+let currentSessionId: string | undefined
+let runCount = 0
+
+export function needsOxlintMigration(root: string): boolean {
+ return (
+ eslintConfigFiles.some(file => existsSync(join(root, file))) &&
+ !Object.keys(CONFIG_FILES).some(file => existsSync(join(root, file)))
+ )
+}
+
+async function startMigration(context: ContextWithTerminals): Promise<{ sessionId?: string }> {
+ const root = context.cwd
+ if (!needsOxlintMigration(root))
+ throw diagnostics.OXDT0005({ reason: 'No eligible ESLint config was found.' })
+
+ try {
+ const packageManager = (await detectPackageManager(root))?.name ?? 'npm'
+ const install = addDependencyCommand(packageManager, 'oxlint@latest', { dev: true })
+ const run = dlxCommand(packageManager, '@oxlint/migrate', { short: true })
+ return startSetup(context, [install, run], 'Migrate ESLint to Oxlint')
+ } catch (error) {
+ if (error instanceof Diagnostic) throw error
+ throw diagnostics.OXDT0005({
+ reason: error instanceof Error ? error.message : String(error),
+ cause: error,
+ })
+ }
+}
+
+async function startInstall(context: ContextWithTerminals): Promise<{ sessionId?: string }> {
+ try {
+ const packageManager = (await detectPackageManager(context.cwd))?.name ?? 'npm'
+ const install = addDependencyCommand(packageManager, 'oxlint@latest', { dev: true })
+ const init = dlxCommand(packageManager, 'oxlint', { args: ['--init'], short: true })
+ return startSetup(context, [install, init], 'Install Oxlint')
+ } catch (error) {
+ if (error instanceof Diagnostic) throw error
+ throw diagnostics.OXDT0005({
+ reason: error instanceof Error ? error.message : String(error),
+ cause: error,
+ })
+ }
+}
+
+async function startSetup(
+ context: ContextWithTerminals,
+ commandLines: string[],
+ title: string,
+): Promise<{ sessionId?: string }> {
+ const terminals = context.terminals
+ if (terminals) {
+ if (currentSessionId && terminals.sessions.get(currentSessionId)?.status === 'running')
+ return { sessionId: currentSessionId }
+
+ const command = commandLines.join(' && ')
+ currentSessionId = `devtools-oxc:setup:${++runCount}`
+ current = await terminals.startChildProcess(
+ process.platform === 'win32'
+ ? { command: 'cmd', args: ['/d', '/s', '/c', command], cwd: context.cwd }
+ : { command: 'sh', args: ['-c', command], cwd: context.cwd },
+ { id: currentSessionId, title, icon: 'ph:terminal-window-duotone' },
+ )
+ return { sessionId: currentSessionId }
+ }
+
+ current = undefined
+ currentSessionId = undefined
+ for (const commandLine of commandLines) {
+ const [command, ...args] = commandLine.split(' ')
+ const result = await x(command!, args, { nodeOptions: { cwd: context.cwd } })
+ if (result.exitCode !== 0) {
+ throw diagnostics.OXDT0005({
+ reason: result.stderr.trim() || `Command exited with code ${result.exitCode ?? 'null'}.`,
+ })
+ }
+ }
+ return {}
+}
+
+async function waitForSetup(): Promise {
+ if (!current) return
+ try {
+ const result = await current.getResult()
+ if (result.exitCode !== 0) {
+ throw diagnostics.OXDT0005({
+ reason:
+ result.stderr.trim() ||
+ `Migration command exited with code ${result.exitCode ?? 'null'}.`,
+ })
+ }
+ } catch (error) {
+ if (error instanceof Diagnostic) throw error
+ throw diagnostics.OXDT0005({
+ reason: error instanceof Error ? error.message : String(error),
+ cause: error,
+ })
+ }
+}
+
+export const oxlintMigrate = defineOxcRpc({
+ name: 'devtools-oxc:migrate-eslint',
+ type: 'action',
+ setup: context => ({
+ handler: () => startMigration(context as ContextWithTerminals),
+ }),
+})
+
+export const oxlintInstall = defineOxcRpc({
+ name: 'devtools-oxc:install-oxlint',
+ type: 'action',
+ setup: context => ({
+ handler: () => startInstall(context as ContextWithTerminals),
+ }),
+})
+
+export const oxlintWaitForSetup = defineOxcRpc({
+ name: 'devtools-oxc:wait-for-setup',
+ type: 'action',
+ setup: () => ({ handler: waitForSetup }),
+})
diff --git a/packages/oxc/src/node/rpc/index.ts b/packages/oxc/src/node/rpc/index.ts
index aee7f05d1..67b87b131 100644
--- a/packages/oxc/src/node/rpc/index.ts
+++ b/packages/oxc/src/node/rpc/index.ts
@@ -9,6 +9,7 @@ import { oxlintGetConfigFile } from './functions/oxlint-get-config-file'
import { oxfmtGetConfigFile } from './functions/oxfmt-get-config-file'
import { openInEditor } from './functions/open-in-editor'
import { getConfigFiles } from './functions/get-config-files'
+import { oxlintInstall, oxlintMigrate, oxlintWaitForSetup } from './functions/oxlint-setup'
export const rpcFunctions = [
oxlintRun,
@@ -20,6 +21,9 @@ export const rpcFunctions = [
oxlintGetConfigFile,
oxfmtGetConfigFile,
getConfigFiles,
+ oxlintMigrate,
+ oxlintInstall,
+ oxlintWaitForSetup,
openInEditor,
] as const
diff --git a/packages/oxc/src/node/utils/config-files.ts b/packages/oxc/src/node/utils/config-files.ts
index 2063e0c36..fd1c8be66 100644
--- a/packages/oxc/src/node/utils/config-files.ts
+++ b/packages/oxc/src/node/utils/config-files.ts
@@ -6,7 +6,7 @@ import { promisify } from 'node:util'
const execFile = promisify(execFileCallback)
-const CONFIG_FILES = {
+export const CONFIG_FILES = {
'.oxlintrc.json': { tool: 'oxlint', format: 'json' },
'.oxlintrc.jsonc': { tool: 'oxlint', format: 'jsonc' },
'oxlint.config.js': { tool: 'oxlint', format: 'js' },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6d3752ce5..a42b309e3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -982,6 +982,9 @@ importers:
nostics:
specifier: catalog:deps
version: 1.2.0
+ nypm:
+ specifier: catalog:deps
+ version: 0.6.9
pathe:
specifier: catalog:deps
version: 2.0.3