pruny 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +56 -0
- package/README.md +65 -0
- package/bin/pruny.js +2 -0
- package/bun.lock +79 -0
- package/package.json +41 -0
- package/src/config.ts +63 -0
- package/src/index.ts +128 -0
- package/src/patterns.ts +46 -0
- package/src/scanner.ts +188 -0
- package/src/types.ts +33 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
push:
|
|
7
|
+
tags:
|
|
8
|
+
- "v*"
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Setup Bun
|
|
17
|
+
uses: oven-sh/setup-bun@v2
|
|
18
|
+
with:
|
|
19
|
+
bun-version: latest
|
|
20
|
+
|
|
21
|
+
- name: Install dependencies
|
|
22
|
+
run: bun install
|
|
23
|
+
|
|
24
|
+
- name: Build
|
|
25
|
+
run: bun run build
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
needs: build
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
permissions:
|
|
31
|
+
contents: read
|
|
32
|
+
id-token: write
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- name: Setup Bun
|
|
37
|
+
uses: oven-sh/setup-bun@v2
|
|
38
|
+
with:
|
|
39
|
+
bun-version: latest
|
|
40
|
+
|
|
41
|
+
- name: Setup Node.js
|
|
42
|
+
uses: actions/setup-node@v4
|
|
43
|
+
with:
|
|
44
|
+
node-version: "20"
|
|
45
|
+
registry-url: "https://registry.npmjs.org"
|
|
46
|
+
|
|
47
|
+
- name: Install dependencies
|
|
48
|
+
run: bun install
|
|
49
|
+
|
|
50
|
+
- name: Build
|
|
51
|
+
run: bun run build
|
|
52
|
+
|
|
53
|
+
- name: Publish to npm
|
|
54
|
+
run: npm publish --access public
|
|
55
|
+
env:
|
|
56
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# pruny
|
|
2
|
+
|
|
3
|
+
Find and remove unused Next.js API routes. 🪓
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g pruny
|
|
9
|
+
# or
|
|
10
|
+
npx pruny
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Scan current directory
|
|
17
|
+
pruny
|
|
18
|
+
|
|
19
|
+
# Scan specific folder
|
|
20
|
+
pruny --dir ./src
|
|
21
|
+
|
|
22
|
+
# Delete unused routes
|
|
23
|
+
pruny --fix
|
|
24
|
+
|
|
25
|
+
# Output as JSON
|
|
26
|
+
pruny --json
|
|
27
|
+
|
|
28
|
+
# Verbose output
|
|
29
|
+
pruny -v
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Config
|
|
33
|
+
|
|
34
|
+
Create `pruny.config.json` (optional):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"dir": "./",
|
|
39
|
+
"ignore": {
|
|
40
|
+
"routes": ["/api/webhooks/**", "/api/cron/**"],
|
|
41
|
+
"folders": ["node_modules", ".next", "dist"],
|
|
42
|
+
"files": ["*.test.ts"]
|
|
43
|
+
},
|
|
44
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx"]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- 🔍 Detects unused Next.js API routes
|
|
51
|
+
- 🗑️ `--fix` flag to delete unused routes
|
|
52
|
+
- ⚡ Auto-detects `vercel.json` cron routes
|
|
53
|
+
- 📁 Default ignores: `node_modules`, `.next`, `dist`, `.git`
|
|
54
|
+
- 🎨 Beautiful CLI output
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
1. Finds all `app/api/**/route.ts` files
|
|
59
|
+
2. Scans codebase for `fetch('/api/...')` patterns
|
|
60
|
+
3. Reports routes with no references
|
|
61
|
+
4. `--fix` deletes the route folder
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
package/bin/pruny.js
ADDED
package/bun.lock
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "unused_variable",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"chalk": "^5.3.0",
|
|
9
|
+
"commander": "^12.0.0",
|
|
10
|
+
"fast-glob": "^3.3.2",
|
|
11
|
+
"minimatch": "^10.1.2",
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "latest",
|
|
15
|
+
"@types/minimatch": "^6.0.0",
|
|
16
|
+
"typescript": "^5.0.0",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"packages": {
|
|
21
|
+
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
|
|
22
|
+
|
|
23
|
+
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
|
|
24
|
+
|
|
25
|
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
|
26
|
+
|
|
27
|
+
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
|
28
|
+
|
|
29
|
+
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
|
30
|
+
|
|
31
|
+
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
|
32
|
+
|
|
33
|
+
"@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="],
|
|
34
|
+
|
|
35
|
+
"@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
|
|
36
|
+
|
|
37
|
+
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
|
38
|
+
|
|
39
|
+
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
|
40
|
+
|
|
41
|
+
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
42
|
+
|
|
43
|
+
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
|
44
|
+
|
|
45
|
+
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
|
46
|
+
|
|
47
|
+
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
|
48
|
+
|
|
49
|
+
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
|
50
|
+
|
|
51
|
+
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
|
52
|
+
|
|
53
|
+
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
|
54
|
+
|
|
55
|
+
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
|
56
|
+
|
|
57
|
+
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
|
58
|
+
|
|
59
|
+
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
|
60
|
+
|
|
61
|
+
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
|
62
|
+
|
|
63
|
+
"minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
|
|
64
|
+
|
|
65
|
+
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
|
66
|
+
|
|
67
|
+
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
|
68
|
+
|
|
69
|
+
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
|
70
|
+
|
|
71
|
+
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
|
72
|
+
|
|
73
|
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
|
74
|
+
|
|
75
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
76
|
+
|
|
77
|
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
78
|
+
}
|
|
79
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pruny",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Find and remove unused Next.js API routes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pruny": "./bin/pruny.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node",
|
|
13
|
+
"dev": "bun run ./src/index.ts",
|
|
14
|
+
"prepublishOnly": "bun run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"unused",
|
|
18
|
+
"api",
|
|
19
|
+
"routes",
|
|
20
|
+
"nextjs",
|
|
21
|
+
"cleanup",
|
|
22
|
+
"lint"
|
|
23
|
+
],
|
|
24
|
+
"author": "webnaresh",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/webnaresh/pruny"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"chalk": "^5.3.0",
|
|
32
|
+
"commander": "^12.0.0",
|
|
33
|
+
"fast-glob": "^3.3.2",
|
|
34
|
+
"minimatch": "^10.1.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"@types/minimatch": "^6.0.0",
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Config } from './types.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG: Config = {
|
|
6
|
+
dir: './',
|
|
7
|
+
ignore: {
|
|
8
|
+
routes: [],
|
|
9
|
+
folders: ['node_modules', '.next', 'dist', '.git', 'coverage', '.turbo'],
|
|
10
|
+
files: ['*.test.ts', '*.spec.ts', '*.test.tsx', '*.spec.tsx'],
|
|
11
|
+
},
|
|
12
|
+
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
interface CLIOptions {
|
|
16
|
+
dir?: string;
|
|
17
|
+
config?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load config from file or use defaults
|
|
22
|
+
*/
|
|
23
|
+
export function loadConfig(options: CLIOptions): Config {
|
|
24
|
+
const configPath = options.config || findConfigFile(options.dir || './');
|
|
25
|
+
|
|
26
|
+
let fileConfig: Partial<Config> = {};
|
|
27
|
+
|
|
28
|
+
if (configPath && existsSync(configPath)) {
|
|
29
|
+
try {
|
|
30
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
31
|
+
fileConfig = JSON.parse(content);
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore parse errors, use defaults
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Merge configs
|
|
38
|
+
return {
|
|
39
|
+
dir: options.dir || fileConfig.dir || DEFAULT_CONFIG.dir,
|
|
40
|
+
ignore: {
|
|
41
|
+
routes: fileConfig.ignore?.routes || DEFAULT_CONFIG.ignore.routes,
|
|
42
|
+
folders: fileConfig.ignore?.folders || DEFAULT_CONFIG.ignore.folders,
|
|
43
|
+
files: fileConfig.ignore?.files || DEFAULT_CONFIG.ignore.files,
|
|
44
|
+
},
|
|
45
|
+
extensions: fileConfig.extensions || DEFAULT_CONFIG.extensions,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find config file in directory
|
|
51
|
+
*/
|
|
52
|
+
function findConfigFile(dir: string): string | null {
|
|
53
|
+
const candidates = ['pruny.config.json', '.prunyrc.json', '.prunyrc'];
|
|
54
|
+
|
|
55
|
+
for (const name of candidates) {
|
|
56
|
+
const path = join(dir, name);
|
|
57
|
+
if (existsSync(path)) {
|
|
58
|
+
return path;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { rmSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { scan } from './scanner.js';
|
|
8
|
+
import { loadConfig } from './config.js';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('pruny')
|
|
14
|
+
.description('Find and remove unused Next.js API routes')
|
|
15
|
+
.version('1.0.0')
|
|
16
|
+
.option('-d, --dir <path>', 'Target directory to scan', './')
|
|
17
|
+
.option('--fix', 'Delete unused API routes')
|
|
18
|
+
.option('-c, --config <path>', 'Path to config file')
|
|
19
|
+
.option('--json', 'Output as JSON')
|
|
20
|
+
.option('-v, --verbose', 'Show detailed info')
|
|
21
|
+
.action(async (options) => {
|
|
22
|
+
const config = loadConfig({
|
|
23
|
+
dir: options.dir,
|
|
24
|
+
config: options.config,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Resolve absolute path
|
|
28
|
+
const absoluteDir = config.dir.startsWith('/')
|
|
29
|
+
? config.dir
|
|
30
|
+
: join(process.cwd(), config.dir);
|
|
31
|
+
config.dir = absoluteDir;
|
|
32
|
+
|
|
33
|
+
if (options.verbose) {
|
|
34
|
+
console.log(chalk.dim('\nConfig:'));
|
|
35
|
+
console.log(chalk.dim(JSON.stringify(config, null, 2)));
|
|
36
|
+
console.log('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(chalk.bold('\n🔍 Scanning for unused API routes...\n'));
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await scan(config);
|
|
43
|
+
|
|
44
|
+
if (options.json) {
|
|
45
|
+
console.log(JSON.stringify(result, null, 2));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Summary
|
|
50
|
+
console.log(chalk.bold('📊 Results\n'));
|
|
51
|
+
console.log(` Total routes: ${result.total}`);
|
|
52
|
+
console.log(chalk.green(` Used routes: ${result.used}`));
|
|
53
|
+
console.log(chalk.red(` Unused routes: ${result.unused}`));
|
|
54
|
+
console.log('');
|
|
55
|
+
|
|
56
|
+
if (result.total === 0) {
|
|
57
|
+
console.log(chalk.yellow('⚠️ No API routes found.\n'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const unused = result.routes.filter((r) => !r.used);
|
|
62
|
+
|
|
63
|
+
if (unused.length === 0) {
|
|
64
|
+
console.log(chalk.green('✅ All API routes are used!\n'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// List unused routes
|
|
69
|
+
console.log(chalk.red.bold('❌ Unused routes:\n'));
|
|
70
|
+
for (const route of unused) {
|
|
71
|
+
console.log(chalk.red(` ${route.path}`));
|
|
72
|
+
console.log(chalk.dim(` → ${route.filePath}`));
|
|
73
|
+
}
|
|
74
|
+
console.log('');
|
|
75
|
+
|
|
76
|
+
// Show used routes in verbose mode
|
|
77
|
+
if (options.verbose) {
|
|
78
|
+
const used = result.routes.filter((r) => r.used);
|
|
79
|
+
if (used.length > 0) {
|
|
80
|
+
console.log(chalk.green.bold('✅ Used routes:\n'));
|
|
81
|
+
for (const route of used) {
|
|
82
|
+
console.log(chalk.green(` ${route.path}`));
|
|
83
|
+
if (route.references.length > 0) {
|
|
84
|
+
for (const ref of route.references.slice(0, 3)) {
|
|
85
|
+
console.log(chalk.dim(` ← ${ref}`));
|
|
86
|
+
}
|
|
87
|
+
if (route.references.length > 3) {
|
|
88
|
+
console.log(
|
|
89
|
+
chalk.dim(` ... and ${route.references.length - 3} more`)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
console.log('');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --fix: Delete unused routes
|
|
99
|
+
if (options.fix) {
|
|
100
|
+
console.log(chalk.yellow.bold('🗑️ Deleting unused routes...\n'));
|
|
101
|
+
|
|
102
|
+
for (const route of unused) {
|
|
103
|
+
const routeDir = dirname(join(config.dir, route.filePath));
|
|
104
|
+
try {
|
|
105
|
+
rmSync(routeDir, { recursive: true, force: true });
|
|
106
|
+
console.log(chalk.red(` Deleted: ${route.filePath}`));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.log(
|
|
109
|
+
chalk.yellow(` Failed to delete: ${route.filePath}`)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
chalk.green(`\n✅ Deleted ${unused.length} unused route(s).\n`)
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
console.log(
|
|
119
|
+
chalk.dim('💡 Run with --fix to delete unused routes.\n')
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(chalk.red('Error scanning:'), err);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
program.parse();
|
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex patterns to detect API route usage in source files
|
|
3
|
+
*/
|
|
4
|
+
export const API_PATTERNS: RegExp[] = [
|
|
5
|
+
// fetch('/api/...')
|
|
6
|
+
/fetch\s*\(\s*['"`]\/api\/([^'"`\s)]+)['"`]/g,
|
|
7
|
+
|
|
8
|
+
// fetch(`/api/...`)
|
|
9
|
+
/fetch\s*\(\s*`\/api\/([^`\s)]+)`/g,
|
|
10
|
+
|
|
11
|
+
// axios.get('/api/...')
|
|
12
|
+
/axios\.\w+\s*\(\s*['"`]\/api\/([^'"`\s)]+)['"`]/g,
|
|
13
|
+
|
|
14
|
+
// useSWR('/api/...')
|
|
15
|
+
/useSWR\s*\(\s*['"`]\/api\/([^'"`\s)]+)['"`]/g,
|
|
16
|
+
|
|
17
|
+
// useQuery with /api/
|
|
18
|
+
/queryFn.*['"`]\/api\/([^'"`\s)]+)['"`]/g,
|
|
19
|
+
|
|
20
|
+
// Generic string containing /api/
|
|
21
|
+
/['"`]\/api\/([^'"`\s]+)['"`]/g,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract all API paths referenced in content
|
|
26
|
+
*/
|
|
27
|
+
export function extractApiPaths(content: string): string[] {
|
|
28
|
+
const paths = new Set<string>();
|
|
29
|
+
|
|
30
|
+
for (const pattern of API_PATTERNS) {
|
|
31
|
+
// Reset regex lastIndex
|
|
32
|
+
pattern.lastIndex = 0;
|
|
33
|
+
|
|
34
|
+
let match: RegExpExecArray | null;
|
|
35
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
36
|
+
// Full path including /api/
|
|
37
|
+
const fullMatch = match[0];
|
|
38
|
+
const pathMatch = fullMatch.match(/\/api\/[^'"`\s)]+/);
|
|
39
|
+
if (pathMatch) {
|
|
40
|
+
paths.add(pathMatch[0]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return Array.from(paths);
|
|
46
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { extractApiPaths } from './patterns.js';
|
|
5
|
+
import type { Config, ApiRoute, ScanResult, VercelConfig } from './types.js';
|
|
6
|
+
import { minimatch } from 'minimatch';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract route path from file path
|
|
10
|
+
* e.g., app/api/users/route.ts -> /api/users
|
|
11
|
+
*/
|
|
12
|
+
function extractRoutePath(filePath: string): string {
|
|
13
|
+
// Remove src/ prefix if present
|
|
14
|
+
let path = filePath.replace(/^src\//, '');
|
|
15
|
+
|
|
16
|
+
// Remove app/ prefix
|
|
17
|
+
path = path.replace(/^app\//, '');
|
|
18
|
+
|
|
19
|
+
// Remove route.{ts,tsx,js,jsx} suffix
|
|
20
|
+
path = path.replace(/\/route\.(ts|tsx|js|jsx)$/, '');
|
|
21
|
+
|
|
22
|
+
return '/' + path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a route matches any ignore pattern
|
|
27
|
+
*/
|
|
28
|
+
function shouldIgnoreRoute(routePath: string, ignorePatterns: string[]): boolean {
|
|
29
|
+
return ignorePatterns.some((pattern) => minimatch(routePath, pattern));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize API path for comparison
|
|
34
|
+
* Removes trailing slashes, query params, dynamic segments for matching
|
|
35
|
+
*/
|
|
36
|
+
function normalizeApiPath(path: string): string {
|
|
37
|
+
return path
|
|
38
|
+
.replace(/\/$/, '') // Remove trailing slash
|
|
39
|
+
.replace(/\?.*$/, '') // Remove query params
|
|
40
|
+
.replace(/\$\{[^}]+\}/g, '*') // Replace template literals with wildcard
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a route is referenced by any of the found paths
|
|
46
|
+
*/
|
|
47
|
+
function isRouteReferenced(routePath: string, foundPaths: string[]): boolean {
|
|
48
|
+
const normalizedRoute = normalizeApiPath(routePath);
|
|
49
|
+
|
|
50
|
+
return foundPaths.some((foundPath) => {
|
|
51
|
+
const normalizedFound = normalizeApiPath(foundPath);
|
|
52
|
+
|
|
53
|
+
// Exact match
|
|
54
|
+
if (normalizedRoute === normalizedFound) return true;
|
|
55
|
+
|
|
56
|
+
// Route is prefix of found path (for dynamic routes)
|
|
57
|
+
if (normalizedFound.startsWith(normalizedRoute)) return true;
|
|
58
|
+
|
|
59
|
+
// Found path matches route pattern
|
|
60
|
+
if (minimatch(normalizedFound, normalizedRoute)) return true;
|
|
61
|
+
|
|
62
|
+
return false;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load vercel.json and get cron paths
|
|
68
|
+
*/
|
|
69
|
+
function getVercelCronPaths(dir: string): string[] {
|
|
70
|
+
const vercelPath = join(dir, 'vercel.json');
|
|
71
|
+
|
|
72
|
+
if (!existsSync(vercelPath)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const content = readFileSync(vercelPath, 'utf-8');
|
|
78
|
+
const config: VercelConfig = JSON.parse(content);
|
|
79
|
+
|
|
80
|
+
if (!config.crons) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return config.crons.map((cron) => cron.path);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Scan for unused API routes
|
|
92
|
+
*/
|
|
93
|
+
export async function scan(config: Config): Promise<ScanResult> {
|
|
94
|
+
const cwd = config.dir;
|
|
95
|
+
|
|
96
|
+
// 1. Find all API route files
|
|
97
|
+
const routePatterns = [
|
|
98
|
+
'app/api/**/route.{ts,tsx,js,jsx}',
|
|
99
|
+
'src/app/api/**/route.{ts,tsx,js,jsx}',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const routeFiles = await fg(routePatterns, {
|
|
103
|
+
cwd,
|
|
104
|
+
ignore: config.ignore.folders,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (routeFiles.length === 0) {
|
|
108
|
+
return { total: 0, used: 0, unused: 0, routes: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Build route map
|
|
112
|
+
const routes: ApiRoute[] = routeFiles.map((file) => ({
|
|
113
|
+
path: extractRoutePath(file),
|
|
114
|
+
filePath: file,
|
|
115
|
+
used: false,
|
|
116
|
+
references: [],
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
// 3. Mark vercel cron routes as used
|
|
120
|
+
const cronPaths = getVercelCronPaths(cwd);
|
|
121
|
+
for (const cronPath of cronPaths) {
|
|
122
|
+
const route = routes.find((r) => r.path === cronPath);
|
|
123
|
+
if (route) {
|
|
124
|
+
route.used = true;
|
|
125
|
+
route.references.push('vercel.json (cron)');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4. Find all source files to scan
|
|
130
|
+
const extGlob = `**/*{${config.extensions.join(',')}}`;
|
|
131
|
+
const sourceFiles = await fg(extGlob, {
|
|
132
|
+
cwd,
|
|
133
|
+
ignore: [...config.ignore.folders, ...config.ignore.files],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 5. Collect all API paths referenced in codebase
|
|
137
|
+
const allReferencedPaths: Set<string> = new Set();
|
|
138
|
+
const fileReferences: Map<string, string[]> = new Map();
|
|
139
|
+
|
|
140
|
+
for (const file of sourceFiles) {
|
|
141
|
+
const filePath = join(cwd, file);
|
|
142
|
+
try {
|
|
143
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
144
|
+
const paths = extractApiPaths(content);
|
|
145
|
+
|
|
146
|
+
if (paths.length > 0) {
|
|
147
|
+
fileReferences.set(file, paths);
|
|
148
|
+
paths.forEach((p) => allReferencedPaths.add(p));
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Skip files that can't be read
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 6. Mark routes as used if referenced
|
|
156
|
+
const referencedArray = Array.from(allReferencedPaths);
|
|
157
|
+
|
|
158
|
+
for (const route of routes) {
|
|
159
|
+
// Skip ignored routes
|
|
160
|
+
if (shouldIgnoreRoute(route.path, config.ignore.routes)) {
|
|
161
|
+
route.used = true;
|
|
162
|
+
route.references.push('(ignored by config)');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check if already marked (e.g., by vercel cron)
|
|
167
|
+
if (route.used) continue;
|
|
168
|
+
|
|
169
|
+
// Check references
|
|
170
|
+
if (isRouteReferenced(route.path, referencedArray)) {
|
|
171
|
+
route.used = true;
|
|
172
|
+
|
|
173
|
+
// Find which files reference this route
|
|
174
|
+
for (const [file, paths] of fileReferences) {
|
|
175
|
+
if (paths.some((p) => isRouteReferenced(route.path, [p]))) {
|
|
176
|
+
route.references.push(file);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
total: routes.length,
|
|
184
|
+
used: routes.filter((r) => r.used).length,
|
|
185
|
+
unused: routes.filter((r) => !r.used).length,
|
|
186
|
+
routes,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface IgnoreConfig {
|
|
2
|
+
routes: string[];
|
|
3
|
+
folders: string[];
|
|
4
|
+
files: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Config {
|
|
8
|
+
dir: string;
|
|
9
|
+
ignore: IgnoreConfig;
|
|
10
|
+
extensions: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiRoute {
|
|
14
|
+
/** API path like /api/users */
|
|
15
|
+
path: string;
|
|
16
|
+
/** File path like app/api/users/route.ts */
|
|
17
|
+
filePath: string;
|
|
18
|
+
/** Whether the route is used */
|
|
19
|
+
used: boolean;
|
|
20
|
+
/** Files that reference this route */
|
|
21
|
+
references: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ScanResult {
|
|
25
|
+
total: number;
|
|
26
|
+
used: number;
|
|
27
|
+
unused: number;
|
|
28
|
+
routes: ApiRoute[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VercelConfig {
|
|
32
|
+
crons?: Array<{ path: string; schedule: string }>;
|
|
33
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"resolveJsonModule": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|