From 9ac010fb36b119b5cd7563a51c97edad651f48a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Miko=C5=82ajczak?= Date: Fri, 6 Mar 2026 15:10:28 +0100 Subject: [PATCH 1/2] feat: add uninit command to reverse init changes Add `agent-react-devtools uninit` command that removes all configuration added by `init`. Supports all frameworks (Vite, Next.js App/Pages Router, CRA) and includes --dry-run flag. This makes it easy to cleanly remove devtools integration without manually tracking which files were modified. --- .../src/__tests__/init.test.ts | 141 ++++++++++++++- packages/agent-react-devtools/src/cli.ts | 9 +- packages/agent-react-devtools/src/init.ts | 164 +++++++++++++++++- 3 files changed, 311 insertions(+), 3 deletions(-) diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts index b13b967..b961750 100644 --- a/packages/agent-react-devtools/src/__tests__/init.test.ts +++ b/packages/agent-react-devtools/src/__tests__/init.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { detectFramework, runInit } from '../init.js'; +import { detectFramework, runInit, runUninit } from '../init.js'; function makeTempDir(): string { return mkdtempSync(join(tmpdir(), 'ard-test-')); @@ -200,3 +200,142 @@ describe('runInit', () => { expect(content).toBe(original); }); }); + +describe('runUninit', () => { + let dir: string; + + beforeEach(() => { + dir = makeTempDir(); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('removes Vite plugin import and usage', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ devDependencies: { '@vitejs/plugin-react': '^4.0.0' } }), + ); + writeFileSync( + join(dir, 'vite.config.ts'), + `import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n`, + ); + + await runInit(dir, false); + const afterInit = readFileSync(join(dir, 'vite.config.ts'), 'utf-8'); + expect(afterInit).toContain('agent-react-devtools'); + + await runUninit(dir, false); + const afterUninit = readFileSync(join(dir, 'vite.config.ts'), 'utf-8'); + expect(afterUninit).not.toContain('agent-react-devtools'); + expect(afterUninit).not.toContain('reactDevtools()'); + expect(afterUninit).toContain("import react from '@vitejs/plugin-react'"); + }); + + it('removes CRA import', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }), + ); + mkdirSync(join(dir, 'src')); + const original = `import React from 'react';\nimport ReactDOM from 'react-dom/client';\n`; + writeFileSync(join(dir, 'src/index.tsx'), original); + + await runInit(dir, false); + const afterInit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(afterInit).toContain('agent-react-devtools'); + + await runUninit(dir, false); + const afterUninit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(afterUninit).not.toContain('agent-react-devtools'); + expect(afterUninit).toContain("import React from 'react'"); + }); + + it('removes Next.js App Router wrapper and import', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { next: '^14.0.0' } }), + ); + mkdirSync(join(dir, 'app')); + writeFileSync( + join(dir, 'app/layout.tsx'), + `export default function Layout({ children }) {\n return {children};\n}\n`, + ); + + await runInit(dir, false); + expect(existsSync(join(dir, 'app/devtools.ts'))).toBe(true); + + await runUninit(dir, false); + expect(existsSync(join(dir, 'app/devtools.ts'))).toBe(false); + const layout = readFileSync(join(dir, 'app/layout.tsx'), 'utf-8'); + expect(layout).not.toContain('devtools'); + }); + + it('removes Next.js Pages Router import', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { next: '^14.0.0' } }), + ); + mkdirSync(join(dir, 'pages')); + const original = `export default function App({ Component, pageProps }) {\n return ;\n}\n`; + writeFileSync(join(dir, 'pages/_app.tsx'), original); + + await runInit(dir, false); + await runUninit(dir, false); + + const content = readFileSync(join(dir, 'pages/_app.tsx'), 'utf-8'); + expect(content).not.toContain('agent-react-devtools'); + }); + + it('dry-run does not modify files', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }), + ); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/index.tsx'), `import React from 'react';\n`); + + await runInit(dir, false); + const afterInit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + + await runUninit(dir, true); + const afterDryRun = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(afterDryRun).toBe(afterInit); + }); + + it('is a no-op when not configured', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }), + ); + mkdirSync(join(dir, 'src')); + const original = `import React from 'react';\n`; + writeFileSync(join(dir, 'src/index.tsx'), original); + + await runUninit(dir, false); + const content = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(content).toBe(original); + }); + + it('init -> uninit -> init roundtrip works', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }), + ); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/index.tsx'), `import React from 'react';\n`); + + await runInit(dir, false); + const afterInit1 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(afterInit1).toContain('agent-react-devtools'); + + await runUninit(dir, false); + const afterUninit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(afterUninit).not.toContain('agent-react-devtools'); + + await runInit(dir, false); + const afterInit2 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); + expect(afterInit2).toContain('agent-react-devtools'); + }); +}); diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index fdc5210..f8acb9c 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -29,6 +29,7 @@ function usage(): string { Setup: init [--dry-run] Auto-configure your React app + uninit [--dry-run] Remove configuration added by init Daemon: start [--port 8097] Start daemon @@ -121,13 +122,19 @@ async function main(): Promise { const cmd1 = command[1]; try { - // ── Init ── + // ── Init / Uninit ── if (cmd0 === 'init') { const { runInit } = await import('./init.js'); await runInit(process.cwd(), flags['dry-run'] === true); return; } + if (cmd0 === 'uninit') { + const { runUninit } = await import('./init.js'); + await runUninit(process.cwd(), flags['dry-run'] === true); + return; + } + // ── Profile diff (no daemon needed) ── if (cmd0 === 'profile' && cmd1 === 'diff') { const fileA = command[2]; diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index 49e134c..581d708 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; import { join, dirname } from 'node:path'; type Framework = 'vite' | 'nextjs' | 'cra' | 'react-native' | 'unknown'; @@ -40,6 +40,21 @@ function prependImport(filePath: string, importLine: string, dryRun: boolean): s return filePath; } +function removeImport(filePath: string, dryRun: boolean): string | null { + const content = readFileSync(filePath, 'utf-8'); + if (!content.includes('agent-react-devtools')) { + return null; // not configured + } + const newContent = content + .split('\n') + .filter((line) => !line.includes('agent-react-devtools')) + .join('\n'); + if (!dryRun) { + writeFileSync(filePath, newContent, 'utf-8'); + } + return filePath; +} + function patchViteConfig(cwd: string, dryRun: boolean): string[] { const configPath = findFile( cwd, @@ -177,6 +192,153 @@ function patchCRA(cwd: string, dryRun: boolean): string[] { return result ? [result] : []; } +function unpatchViteConfig(cwd: string, dryRun: boolean): string[] { + const configPath = findFile( + cwd, + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mts', + 'vite.config.mjs', + ); + if (!configPath) return []; + + const content = readFileSync(configPath, 'utf-8'); + if (!content.includes('agent-react-devtools')) return []; + + let newContent = content + .split('\n') + .filter((line) => !line.includes('agent-react-devtools')) + .join('\n'); + + // Remove reactDevtools() call from plugins array (with optional trailing comma) + newContent = newContent.replace(/\s*reactDevtools\(\),?/g, ''); + + if (!dryRun) { + writeFileSync(configPath, newContent, 'utf-8'); + } + return [configPath]; +} + +function unpatchNextJs(cwd: string, dryRun: boolean): string[] { + const modified: string[] = []; + + // Remove the devtools.ts wrapper file if it exists and is ours + const layoutPath = findFile( + cwd, + 'app/layout.tsx', + 'app/layout.jsx', + 'app/layout.js', + 'src/app/layout.tsx', + 'src/app/layout.jsx', + 'src/app/layout.js', + ); + + if (layoutPath) { + const devtoolsPath = join(dirname(layoutPath), 'devtools.ts'); + if (existsSync(devtoolsPath)) { + const content = readFileSync(devtoolsPath, 'utf-8'); + if (content.includes('agent-react-devtools')) { + if (!dryRun) { + unlinkSync(devtoolsPath); + } + modified.push(devtoolsPath); + } + } + + // Remove the import of ./devtools from layout + const layoutContent = readFileSync(layoutPath, 'utf-8'); + if (layoutContent.includes("'./devtools'") || layoutContent.includes('agent-react-devtools')) { + const newContent = layoutContent + .split('\n') + .filter((line) => !line.includes("'./devtools'") && !line.includes('agent-react-devtools')) + .join('\n'); + if (!dryRun) { + writeFileSync(layoutPath, newContent, 'utf-8'); + } + modified.push(layoutPath); + } + } + + // Also check Pages Router + const pagesEntry = findFile( + cwd, + 'pages/_app.tsx', + 'pages/_app.jsx', + 'pages/_app.js', + 'src/pages/_app.tsx', + 'src/pages/_app.jsx', + 'src/pages/_app.js', + ); + if (pagesEntry) { + const result = removeImport(pagesEntry, dryRun); + if (result) modified.push(result); + } + + return modified; +} + +function unpatchCRA(cwd: string, dryRun: boolean): string[] { + const entryPath = findFile( + cwd, + 'src/index.tsx', + 'src/index.jsx', + 'src/index.js', + ); + if (!entryPath) return []; + + const result = removeImport(entryPath, dryRun); + return result ? [result] : []; +} + +export async function runUninit( + cwd: string, + dryRun: boolean, +): Promise { + const framework = detectFramework(cwd); + + console.log(`Detected framework: ${framework}`); + + if (framework === 'unknown') { + console.log('\nCould not detect framework. Manual removal required:'); + console.log(" Remove any `import 'agent-react-devtools/connect'` lines"); + return; + } + + if (framework === 'react-native') { + console.log('\nReact Native detected - no code changes were made by init.'); + return; + } + + let modified: string[] = []; + + if (dryRun) { + console.log('\n[dry-run] Would modify:'); + } + + switch (framework) { + case 'vite': + modified = unpatchViteConfig(cwd, dryRun); + break; + case 'nextjs': + modified = unpatchNextJs(cwd, dryRun); + break; + case 'cra': + modified = unpatchCRA(cwd, dryRun); + break; + } + + if (modified.length === 0) { + console.log(' No changes needed (not configured or already removed)'); + return; + } + + for (const f of modified) { + console.log(` ${dryRun ? '[dry-run] ' : ''}Reverted: ${f}`); + } + + console.log('\nDone! agent-react-devtools configuration has been removed.'); +} + export async function runInit( cwd: string, dryRun: boolean, From 5087ddabfb3bd197370d3039ce180c12ded03ed2 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Fri, 3 Apr 2026 00:30:21 +0200 Subject: [PATCH 2/2] fix(uninit): scope import removal to exact lines, gate layout patch on ownership - removeImport now matches the exact import string instead of any line containing 'agent-react-devtools', preventing accidental removal of unrelated comments or imports - unpatchViteConfig filters the exact import line inserted by init - unpatchNextJs only removes the layout's import './devtools' when devtools.ts was confirmed to be ours, preventing corruption of pre-existing imports with the same name - Add changeset and README entry for uninit command --- .changeset/uninit-command.md | 11 ++++++++ README.md | 6 +++++ packages/agent-react-devtools/src/init.ts | 31 +++++++++++++---------- 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 .changeset/uninit-command.md diff --git a/.changeset/uninit-command.md b/.changeset/uninit-command.md new file mode 100644 index 0000000..58d97e4 --- /dev/null +++ b/.changeset/uninit-command.md @@ -0,0 +1,11 @@ +--- +"agent-react-devtools": minor +--- + +Add `uninit` command to reverse framework configuration + +`agent-react-devtools uninit` removes the changes made by `init` — restoring your config files to their original state. + +- Supports all frameworks: Vite, Next.js (Pages Router and App Router), CRA +- `--dry-run` flag previews what would be removed without writing any files +- Safe to run on projects not configured by `init` (no-op) diff --git a/README.md b/README.md index 521c89c..e98c7ae 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,12 @@ npx agent-react-devtools init This detects your framework (Vite, Next.js, CRA) and patches the appropriate config file. +To undo these changes: + +```sh +npx agent-react-devtools uninit +``` + ### One-line import Add a single import as the first line of your entry point (e.g. `src/main.tsx`): diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index 581d708..c961346 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -40,14 +40,14 @@ function prependImport(filePath: string, importLine: string, dryRun: boolean): s return filePath; } -function removeImport(filePath: string, dryRun: boolean): string | null { +function removeImport(filePath: string, importLine: string, dryRun: boolean): string | null { const content = readFileSync(filePath, 'utf-8'); - if (!content.includes('agent-react-devtools')) { + if (!content.includes(importLine)) { return null; // not configured } const newContent = content .split('\n') - .filter((line) => !line.includes('agent-react-devtools')) + .filter((line) => line !== importLine) .join('\n'); if (!dryRun) { writeFileSync(filePath, newContent, 'utf-8'); @@ -207,7 +207,7 @@ function unpatchViteConfig(cwd: string, dryRun: boolean): string[] { let newContent = content .split('\n') - .filter((line) => !line.includes('agent-react-devtools')) + .filter((line) => line !== "import { reactDevtools } from 'agent-react-devtools/vite';") .join('\n'); // Remove reactDevtools() call from plugins array (with optional trailing comma) @@ -235,9 +235,11 @@ function unpatchNextJs(cwd: string, dryRun: boolean): string[] { if (layoutPath) { const devtoolsPath = join(dirname(layoutPath), 'devtools.ts'); + let devtoolsIsOurs = false; if (existsSync(devtoolsPath)) { const content = readFileSync(devtoolsPath, 'utf-8'); if (content.includes('agent-react-devtools')) { + devtoolsIsOurs = true; if (!dryRun) { unlinkSync(devtoolsPath); } @@ -245,17 +247,20 @@ function unpatchNextJs(cwd: string, dryRun: boolean): string[] { } } - // Remove the import of ./devtools from layout - const layoutContent = readFileSync(layoutPath, 'utf-8'); - if (layoutContent.includes("'./devtools'") || layoutContent.includes('agent-react-devtools')) { + // Only remove the layout import if we confirmed devtools.ts was created by us, + // to avoid corrupting a pre-existing import './devtools' that we don't own. + if (devtoolsIsOurs) { + const layoutContent = readFileSync(layoutPath, 'utf-8'); const newContent = layoutContent .split('\n') - .filter((line) => !line.includes("'./devtools'") && !line.includes('agent-react-devtools')) + .filter((line) => line !== "import './devtools';") .join('\n'); - if (!dryRun) { - writeFileSync(layoutPath, newContent, 'utf-8'); + if (newContent !== layoutContent) { + if (!dryRun) { + writeFileSync(layoutPath, newContent, 'utf-8'); + } + modified.push(layoutPath); } - modified.push(layoutPath); } } @@ -270,7 +275,7 @@ function unpatchNextJs(cwd: string, dryRun: boolean): string[] { 'src/pages/_app.js', ); if (pagesEntry) { - const result = removeImport(pagesEntry, dryRun); + const result = removeImport(pagesEntry, "import 'agent-react-devtools/connect';", dryRun); if (result) modified.push(result); } @@ -286,7 +291,7 @@ function unpatchCRA(cwd: string, dryRun: boolean): string[] { ); if (!entryPath) return []; - const result = removeImport(entryPath, dryRun); + const result = removeImport(entryPath, "import 'agent-react-devtools/connect';", dryRun); return result ? [result] : []; }