Skip to content

Commit 95b3cbb

Browse files
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.
1 parent 05090ca commit 95b3cbb

3 files changed

Lines changed: 311 additions & 3 deletions

File tree

‎packages/agent-react-devtools/src/__tests__/init.test.ts‎

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { tmpdir } from 'node:os';
5-
import { detectFramework, runInit } from '../init.js';
5+
import { detectFramework, runInit, runUninit } from '../init.js';
66

77
function makeTempDir(): string {
88
return mkdtempSync(join(tmpdir(), 'ard-test-'));
@@ -200,3 +200,142 @@ describe('runInit', () => {
200200
expect(content).toBe(original);
201201
});
202202
});
203+
204+
describe('runUninit', () => {
205+
let dir: string;
206+
207+
beforeEach(() => {
208+
dir = makeTempDir();
209+
});
210+
211+
afterEach(() => {
212+
rmSync(dir, { recursive: true, force: true });
213+
});
214+
215+
it('removes Vite plugin import and usage', async () => {
216+
writeFileSync(
217+
join(dir, 'package.json'),
218+
JSON.stringify({ devDependencies: { '@vitejs/plugin-react': '^4.0.0' } }),
219+
);
220+
writeFileSync(
221+
join(dir, 'vite.config.ts'),
222+
`import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n`,
223+
);
224+
225+
await runInit(dir, false);
226+
const afterInit = readFileSync(join(dir, 'vite.config.ts'), 'utf-8');
227+
expect(afterInit).toContain('agent-react-devtools');
228+
229+
await runUninit(dir, false);
230+
const afterUninit = readFileSync(join(dir, 'vite.config.ts'), 'utf-8');
231+
expect(afterUninit).not.toContain('agent-react-devtools');
232+
expect(afterUninit).not.toContain('reactDevtools()');
233+
expect(afterUninit).toContain("import react from '@vitejs/plugin-react'");
234+
});
235+
236+
it('removes CRA import', async () => {
237+
writeFileSync(
238+
join(dir, 'package.json'),
239+
JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
240+
);
241+
mkdirSync(join(dir, 'src'));
242+
const original = `import React from 'react';\nimport ReactDOM from 'react-dom/client';\n`;
243+
writeFileSync(join(dir, 'src/index.tsx'), original);
244+
245+
await runInit(dir, false);
246+
const afterInit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
247+
expect(afterInit).toContain('agent-react-devtools');
248+
249+
await runUninit(dir, false);
250+
const afterUninit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
251+
expect(afterUninit).not.toContain('agent-react-devtools');
252+
expect(afterUninit).toContain("import React from 'react'");
253+
});
254+
255+
it('removes Next.js App Router wrapper and import', async () => {
256+
writeFileSync(
257+
join(dir, 'package.json'),
258+
JSON.stringify({ dependencies: { next: '^14.0.0' } }),
259+
);
260+
mkdirSync(join(dir, 'app'));
261+
writeFileSync(
262+
join(dir, 'app/layout.tsx'),
263+
`export default function Layout({ children }) {\n return <html><body>{children}</body></html>;\n}\n`,
264+
);
265+
266+
await runInit(dir, false);
267+
expect(existsSync(join(dir, 'app/devtools.ts'))).toBe(true);
268+
269+
await runUninit(dir, false);
270+
expect(existsSync(join(dir, 'app/devtools.ts'))).toBe(false);
271+
const layout = readFileSync(join(dir, 'app/layout.tsx'), 'utf-8');
272+
expect(layout).not.toContain('devtools');
273+
});
274+
275+
it('removes Next.js Pages Router import', async () => {
276+
writeFileSync(
277+
join(dir, 'package.json'),
278+
JSON.stringify({ dependencies: { next: '^14.0.0' } }),
279+
);
280+
mkdirSync(join(dir, 'pages'));
281+
const original = `export default function App({ Component, pageProps }) {\n return <Component {...pageProps} />;\n}\n`;
282+
writeFileSync(join(dir, 'pages/_app.tsx'), original);
283+
284+
await runInit(dir, false);
285+
await runUninit(dir, false);
286+
287+
const content = readFileSync(join(dir, 'pages/_app.tsx'), 'utf-8');
288+
expect(content).not.toContain('agent-react-devtools');
289+
});
290+
291+
it('dry-run does not modify files', async () => {
292+
writeFileSync(
293+
join(dir, 'package.json'),
294+
JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
295+
);
296+
mkdirSync(join(dir, 'src'));
297+
writeFileSync(join(dir, 'src/index.tsx'), `import React from 'react';\n`);
298+
299+
await runInit(dir, false);
300+
const afterInit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
301+
302+
await runUninit(dir, true);
303+
const afterDryRun = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
304+
expect(afterDryRun).toBe(afterInit);
305+
});
306+
307+
it('is a no-op when not configured', async () => {
308+
writeFileSync(
309+
join(dir, 'package.json'),
310+
JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
311+
);
312+
mkdirSync(join(dir, 'src'));
313+
const original = `import React from 'react';\n`;
314+
writeFileSync(join(dir, 'src/index.tsx'), original);
315+
316+
await runUninit(dir, false);
317+
const content = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
318+
expect(content).toBe(original);
319+
});
320+
321+
it('init -> uninit -> init roundtrip works', async () => {
322+
writeFileSync(
323+
join(dir, 'package.json'),
324+
JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
325+
);
326+
mkdirSync(join(dir, 'src'));
327+
writeFileSync(join(dir, 'src/index.tsx'), `import React from 'react';\n`);
328+
329+
await runInit(dir, false);
330+
const afterInit1 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
331+
expect(afterInit1).toContain('agent-react-devtools');
332+
333+
await runUninit(dir, false);
334+
const afterUninit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
335+
expect(afterUninit).not.toContain('agent-react-devtools');
336+
337+
await runInit(dir, false);
338+
const afterInit2 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
339+
expect(afterInit2).toContain('agent-react-devtools');
340+
});
341+
});

‎packages/agent-react-devtools/src/cli.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function usage(): string {
2525
2626
Setup:
2727
init [--dry-run] Auto-configure your React app
28+
uninit [--dry-run] Remove configuration added by init
2829
2930
Daemon:
3031
start [--port 8097] Start daemon
@@ -114,13 +115,19 @@ async function main(): Promise<void> {
114115
const cmd1 = command[1];
115116

116117
try {
117-
// ── Init ──
118+
// ── Init / Uninit ──
118119
if (cmd0 === 'init') {
119120
const { runInit } = await import('./init.js');
120121
await runInit(process.cwd(), flags['dry-run'] === true);
121122
return;
122123
}
123124

125+
if (cmd0 === 'uninit') {
126+
const { runUninit } = await import('./init.js');
127+
await runUninit(process.cwd(), flags['dry-run'] === true);
128+
return;
129+
}
130+
124131
// ── Daemon management ──
125132
if (cmd0 === 'start') {
126133
const port = parseNumericFlag(flags, 'port');

‎packages/agent-react-devtools/src/init.ts‎

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
1+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
22
import { join, dirname } from 'node:path';
33

44
type Framework = 'vite' | 'nextjs' | 'cra' | 'react-native' | 'unknown';
@@ -40,6 +40,21 @@ function prependImport(filePath: string, importLine: string, dryRun: boolean): s
4040
return filePath;
4141
}
4242

43+
function removeImport(filePath: string, dryRun: boolean): string | null {
44+
const content = readFileSync(filePath, 'utf-8');
45+
if (!content.includes('agent-react-devtools')) {
46+
return null; // not configured
47+
}
48+
const newContent = content
49+
.split('\n')
50+
.filter((line) => !line.includes('agent-react-devtools'))
51+
.join('\n');
52+
if (!dryRun) {
53+
writeFileSync(filePath, newContent, 'utf-8');
54+
}
55+
return filePath;
56+
}
57+
4358
function patchViteConfig(cwd: string, dryRun: boolean): string[] {
4459
const configPath = findFile(
4560
cwd,
@@ -177,6 +192,153 @@ function patchCRA(cwd: string, dryRun: boolean): string[] {
177192
return result ? [result] : [];
178193
}
179194

195+
function unpatchViteConfig(cwd: string, dryRun: boolean): string[] {
196+
const configPath = findFile(
197+
cwd,
198+
'vite.config.ts',
199+
'vite.config.js',
200+
'vite.config.mts',
201+
'vite.config.mjs',
202+
);
203+
if (!configPath) return [];
204+
205+
const content = readFileSync(configPath, 'utf-8');
206+
if (!content.includes('agent-react-devtools')) return [];
207+
208+
let newContent = content
209+
.split('\n')
210+
.filter((line) => !line.includes('agent-react-devtools'))
211+
.join('\n');
212+
213+
// Remove reactDevtools() call from plugins array (with optional trailing comma)
214+
newContent = newContent.replace(/\s*reactDevtools\(\),?/g, '');
215+
216+
if (!dryRun) {
217+
writeFileSync(configPath, newContent, 'utf-8');
218+
}
219+
return [configPath];
220+
}
221+
222+
function unpatchNextJs(cwd: string, dryRun: boolean): string[] {
223+
const modified: string[] = [];
224+
225+
// Remove the devtools.ts wrapper file if it exists and is ours
226+
const layoutPath = findFile(
227+
cwd,
228+
'app/layout.tsx',
229+
'app/layout.jsx',
230+
'app/layout.js',
231+
'src/app/layout.tsx',
232+
'src/app/layout.jsx',
233+
'src/app/layout.js',
234+
);
235+
236+
if (layoutPath) {
237+
const devtoolsPath = join(dirname(layoutPath), 'devtools.ts');
238+
if (existsSync(devtoolsPath)) {
239+
const content = readFileSync(devtoolsPath, 'utf-8');
240+
if (content.includes('agent-react-devtools')) {
241+
if (!dryRun) {
242+
unlinkSync(devtoolsPath);
243+
}
244+
modified.push(devtoolsPath);
245+
}
246+
}
247+
248+
// Remove the import of ./devtools from layout
249+
const layoutContent = readFileSync(layoutPath, 'utf-8');
250+
if (layoutContent.includes("'./devtools'") || layoutContent.includes('agent-react-devtools')) {
251+
const newContent = layoutContent
252+
.split('\n')
253+
.filter((line) => !line.includes("'./devtools'") && !line.includes('agent-react-devtools'))
254+
.join('\n');
255+
if (!dryRun) {
256+
writeFileSync(layoutPath, newContent, 'utf-8');
257+
}
258+
modified.push(layoutPath);
259+
}
260+
}
261+
262+
// Also check Pages Router
263+
const pagesEntry = findFile(
264+
cwd,
265+
'pages/_app.tsx',
266+
'pages/_app.jsx',
267+
'pages/_app.js',
268+
'src/pages/_app.tsx',
269+
'src/pages/_app.jsx',
270+
'src/pages/_app.js',
271+
);
272+
if (pagesEntry) {
273+
const result = removeImport(pagesEntry, dryRun);
274+
if (result) modified.push(result);
275+
}
276+
277+
return modified;
278+
}
279+
280+
function unpatchCRA(cwd: string, dryRun: boolean): string[] {
281+
const entryPath = findFile(
282+
cwd,
283+
'src/index.tsx',
284+
'src/index.jsx',
285+
'src/index.js',
286+
);
287+
if (!entryPath) return [];
288+
289+
const result = removeImport(entryPath, dryRun);
290+
return result ? [result] : [];
291+
}
292+
293+
export async function runUninit(
294+
cwd: string,
295+
dryRun: boolean,
296+
): Promise<void> {
297+
const framework = detectFramework(cwd);
298+
299+
console.log(`Detected framework: ${framework}`);
300+
301+
if (framework === 'unknown') {
302+
console.log('\nCould not detect framework. Manual removal required:');
303+
console.log(" Remove any `import 'agent-react-devtools/connect'` lines");
304+
return;
305+
}
306+
307+
if (framework === 'react-native') {
308+
console.log('\nReact Native detected - no code changes were made by init.');
309+
return;
310+
}
311+
312+
let modified: string[] = [];
313+
314+
if (dryRun) {
315+
console.log('\n[dry-run] Would modify:');
316+
}
317+
318+
switch (framework) {
319+
case 'vite':
320+
modified = unpatchViteConfig(cwd, dryRun);
321+
break;
322+
case 'nextjs':
323+
modified = unpatchNextJs(cwd, dryRun);
324+
break;
325+
case 'cra':
326+
modified = unpatchCRA(cwd, dryRun);
327+
break;
328+
}
329+
330+
if (modified.length === 0) {
331+
console.log(' No changes needed (not configured or already removed)');
332+
return;
333+
}
334+
335+
for (const f of modified) {
336+
console.log(` ${dryRun ? '[dry-run] ' : ''}Reverted: ${f}`);
337+
}
338+
339+
console.log('\nDone! agent-react-devtools configuration has been removed.');
340+
}
341+
180342
export async function runInit(
181343
cwd: string,
182344
dryRun: boolean,

0 commit comments

Comments
 (0)