Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/uninit-command.md
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
141 changes: 140 additions & 1 deletion packages/agent-react-devtools/src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -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 <html><body>{children}</body></html>;\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 <Component {...pageProps} />;\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');
});
});
9 changes: 8 additions & 1 deletion packages/agent-react-devtools/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,13 +122,19 @@ async function main(): Promise<void> {
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];
Expand Down
169 changes: 168 additions & 1 deletion packages/agent-react-devtools/src/init.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,6 +40,21 @@ function prependImport(filePath: string, importLine: string, dryRun: boolean): s
return filePath;
}

function removeImport(filePath: string, importLine: string, dryRun: boolean): string | null {
const content = readFileSync(filePath, 'utf-8');
if (!content.includes(importLine)) {
return null; // not configured
}
const newContent = content
.split('\n')
.filter((line) => line !== importLine)
.join('\n');
if (!dryRun) {
writeFileSync(filePath, newContent, 'utf-8');
}
return filePath;
}

function patchViteConfig(cwd: string, dryRun: boolean): string[] {
const configPath = findFile(
cwd,
Expand Down Expand Up @@ -177,6 +192,158 @@ 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 !== "import { reactDevtools } from 'agent-react-devtools/vite';")
.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');
let devtoolsIsOurs = false;
if (existsSync(devtoolsPath)) {
const content = readFileSync(devtoolsPath, 'utf-8');
if (content.includes('agent-react-devtools')) {
devtoolsIsOurs = true;
if (!dryRun) {
unlinkSync(devtoolsPath);
}
modified.push(devtoolsPath);
}
}

// 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 !== "import './devtools';")
.join('\n');
if (newContent !== layoutContent) {
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, "import 'agent-react-devtools/connect';", 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, "import 'agent-react-devtools/connect';", dryRun);
return result ? [result] : [];
}

export async function runUninit(
cwd: string,
dryRun: boolean,
): Promise<void> {
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,
Expand Down
Loading