apex-auditor 0.3.5 → 0.3.7
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/README.md +10 -0
- package/dist/bin.js +14 -0
- package/dist/cli.js +19 -15
- package/dist/lighthouse-runner.js +13 -1
- package/dist/lighthouse-worker.js +17 -4
- package/dist/route-detectors.js +77 -0
- package/dist/shell-cli.js +316 -105
- package/dist/uninstall-cli.js +156 -0
- package/dist/wizard-cli.js +169 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,9 @@ Notes:
|
|
|
20
20
|
|
|
21
21
|
- `init` can auto-detect your stack from `package.json` (Next.js, Nuxt, Remix/React Router, SvelteKit, SPA).
|
|
22
22
|
- In monorepos, `init` can prompt you to pick an app/package under `apps/` or `packages/`.
|
|
23
|
+
- `init` can auto-discover routes from the filesystem and top-up from `robots.txt`/`sitemap.xml`. You can optionally filter detected routes with include/exclude patterns and still add manual routes. For larger route sets, the wizard may default filtering to **Yes** and prefill common excludes (framework-specific).
|
|
24
|
+
- For static sites, `init` can discover routes from HTML files under `dist/`, `build/`, `out/`, `public/`, and `src/`.
|
|
25
|
+
- When using a localhost base URL (e.g. `http://localhost:3000`), make sure the dev server port matches the project you’re configuring (important when multiple projects are running).
|
|
23
26
|
|
|
24
27
|
Inside the interactive shell:
|
|
25
28
|
|
|
@@ -31,6 +34,9 @@ Inside the interactive shell:
|
|
|
31
34
|
- **headers** (security headers check; writes `.apex-auditor/headers.json`)
|
|
32
35
|
- **console** (console errors + runtime exceptions; writes `.apex-auditor/console.json`)
|
|
33
36
|
- **open** (open the latest HTML report)
|
|
37
|
+
- **pages** / **routes** (print configured pages/routes from the current config)
|
|
38
|
+
- **add-page** (interactive: append a page to `apex.config.json`)
|
|
39
|
+
- **rm-page** (interactive: remove a page from `apex.config.json`)
|
|
34
40
|
- **init** (launch config wizard)
|
|
35
41
|
- **config <path>** (switch config file)
|
|
36
42
|
|
|
@@ -127,6 +133,10 @@ The docs in `docs/` reflect the current shell-based workflow:
|
|
|
127
133
|
- `docs/configuration-and-routes.md`
|
|
128
134
|
- `docs/cli-and-ci.md`
|
|
129
135
|
|
|
136
|
+
## Known issues
|
|
137
|
+
|
|
138
|
+
- **Large-run Lighthouse stability**: very large audits (many page/device combinations) may show higher score variance than manual Lighthouse runs and can intermittently hit worker/Chrome disconnects. Workaround: reduce parallelism (e.g. `--stable`) and retry.
|
|
139
|
+
|
|
130
140
|
## License
|
|
131
141
|
|
|
132
142
|
MIT
|
package/dist/bin.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runLinksCli } from "./links-cli.js";
|
|
|
10
10
|
import { runHeadersCli } from "./headers-cli.js";
|
|
11
11
|
import { runConsoleCli } from "./console-cli.js";
|
|
12
12
|
import { runCleanCli } from "./clean-cli.js";
|
|
13
|
+
import { runUninstallCli } from "./uninstall-cli.js";
|
|
13
14
|
function parseBinArgs(argv) {
|
|
14
15
|
const rawCommand = argv[2];
|
|
15
16
|
if (rawCommand === undefined) {
|
|
@@ -30,6 +31,7 @@ function parseBinArgs(argv) {
|
|
|
30
31
|
rawCommand === "headers" ||
|
|
31
32
|
rawCommand === "console" ||
|
|
32
33
|
rawCommand === "clean" ||
|
|
34
|
+
rawCommand === "uninstall" ||
|
|
33
35
|
rawCommand === "wizard" ||
|
|
34
36
|
rawCommand === "quickstart" ||
|
|
35
37
|
rawCommand === "guide" ||
|
|
@@ -126,6 +128,7 @@ function printHelp(topic) {
|
|
|
126
128
|
" headers Security headers audit",
|
|
127
129
|
" console Console errors + runtime exceptions audit (headless Chrome)",
|
|
128
130
|
" clean Remove ApexAuditor artifacts (reports/cache and optionally config)",
|
|
131
|
+
" uninstall One-click uninstall (removes .apex-auditor/ and apex.config.json)",
|
|
129
132
|
" help Show this help message",
|
|
130
133
|
"",
|
|
131
134
|
"Options (audit):",
|
|
@@ -207,6 +210,13 @@ function printHelp(topic) {
|
|
|
207
210
|
" --yes, -y Skip confirmation prompt",
|
|
208
211
|
" --json Print JSON report to stdout",
|
|
209
212
|
"",
|
|
213
|
+
"Options (uninstall):",
|
|
214
|
+
" --project-root <path> Project root (default cwd)",
|
|
215
|
+
" --config-path <path> Config file path relative to project root (default apex.config.json)",
|
|
216
|
+
" --dry-run Print planned removals without deleting",
|
|
217
|
+
" --yes, -y Skip confirmation prompt",
|
|
218
|
+
" --json Print JSON report to stdout",
|
|
219
|
+
"",
|
|
210
220
|
"Outputs:",
|
|
211
221
|
" - Writes .apex-auditor/summary.json, summary.md, report.html",
|
|
212
222
|
" - Prints a file:// link to the HTML report after completion",
|
|
@@ -282,6 +292,10 @@ export async function runBin(argv) {
|
|
|
282
292
|
await runCleanCli(parsed.argv);
|
|
283
293
|
return;
|
|
284
294
|
}
|
|
295
|
+
if (parsed.command === "uninstall") {
|
|
296
|
+
await runUninstallCli(parsed.argv);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
285
299
|
if (parsed.command === "init" || parsed.command === "wizard" || parsed.command === "guide") {
|
|
286
300
|
await runWizardCli(parsed.argv);
|
|
287
301
|
return;
|
package/dist/cli.js
CHANGED
|
@@ -1139,15 +1139,24 @@ export async function runAuditCli(argv, options) {
|
|
|
1139
1139
|
const plannedCombos = overviewSample.config.pages.reduce((acc, p) => acc + p.devices.length, 0);
|
|
1140
1140
|
const plannedRuns = overviewSample.config.runs ?? 1;
|
|
1141
1141
|
const plannedSteps = plannedCombos * plannedRuns;
|
|
1142
|
+
const LARGE_RUN_COMBOS_THRESHOLD = 76;
|
|
1143
|
+
const LARGE_RUN_PARALLEL_CAP = 4;
|
|
1144
|
+
const usingDefaultParallel = args.parallelOverride === undefined && presetParallel === undefined && config.parallel === undefined;
|
|
1145
|
+
const isLargeRun = plannedCombos >= LARGE_RUN_COMBOS_THRESHOLD;
|
|
1146
|
+
const autoStableLargeRun = isLargeRun && !args.stable && usingDefaultParallel;
|
|
1147
|
+
const resolvedConfigForRun = autoStableLargeRun
|
|
1148
|
+
? { ...overviewSample.config, parallel: Math.min(overviewSample.config.parallel ?? DEFAULT_PARALLEL, LARGE_RUN_PARALLEL_CAP) }
|
|
1149
|
+
: overviewSample.config;
|
|
1142
1150
|
const maxSteps = args.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
1143
1151
|
const maxCombos = args.maxCombos ?? DEFAULT_MAX_COMBOS;
|
|
1144
1152
|
const isTty = typeof process !== "undefined" && process.stdout?.isTTY === true;
|
|
1153
|
+
const LARGE_RUN_HINT_COMBOS_THRESHOLD = 40;
|
|
1145
1154
|
const exceeds = plannedSteps > maxSteps || plannedCombos > maxCombos;
|
|
1146
1155
|
const useColor = shouldUseColor(args.ci, args.colorMode);
|
|
1147
1156
|
if (args.plan) {
|
|
1148
1157
|
printPlan({
|
|
1149
1158
|
configPath,
|
|
1150
|
-
resolvedConfig:
|
|
1159
|
+
resolvedConfig: resolvedConfigForRun,
|
|
1151
1160
|
plannedCombos,
|
|
1152
1161
|
plannedSteps,
|
|
1153
1162
|
sampled: overviewSample.sampled,
|
|
@@ -1158,15 +1167,13 @@ export async function runAuditCli(argv, options) {
|
|
|
1158
1167
|
});
|
|
1159
1168
|
return;
|
|
1160
1169
|
}
|
|
1161
|
-
if (isTty && !args.ci && !args.jsonOutput) {
|
|
1162
|
-
const tipLines = [
|
|
1163
|
-
"Tip: use --plan to preview run size before starting.",
|
|
1164
|
-
"Note: runs-per-combo is always 1; rerun the same command to compare results.",
|
|
1165
|
-
"If parallel mode flakes (worker disconnects), retry with --stable (forces parallel=1).",
|
|
1166
|
-
];
|
|
1170
|
+
if (isTty && !args.ci && !args.jsonOutput && plannedCombos >= LARGE_RUN_HINT_COMBOS_THRESHOLD) {
|
|
1167
1171
|
// eslint-disable-next-line no-console
|
|
1168
|
-
console.log(
|
|
1169
|
-
|
|
1172
|
+
console.log(`Tip: large run (${plannedCombos} combos). Use --plan to preview, and retry with --stable if parallel mode flakes.`);
|
|
1173
|
+
}
|
|
1174
|
+
if (autoStableLargeRun && isTty && !args.ci && !args.jsonOutput) {
|
|
1175
|
+
// eslint-disable-next-line no-console
|
|
1176
|
+
console.log(`Large run detected (${plannedCombos} combos). Using stability mode: parallel capped to ${resolvedConfigForRun.parallel}. Override with --parallel <n> or --stable (parallel=1).`);
|
|
1170
1177
|
}
|
|
1171
1178
|
if (overviewSample.sampled) {
|
|
1172
1179
|
// eslint-disable-next-line no-console
|
|
@@ -1233,7 +1240,7 @@ export async function runAuditCli(argv, options) {
|
|
|
1233
1240
|
}
|
|
1234
1241
|
try {
|
|
1235
1242
|
summary = await runAuditsForConfig({
|
|
1236
|
-
config:
|
|
1243
|
+
config: resolvedConfigForRun,
|
|
1237
1244
|
configPath,
|
|
1238
1245
|
showParallel: args.showParallel,
|
|
1239
1246
|
onlyCategories,
|
|
@@ -1244,11 +1251,8 @@ export async function runAuditCli(argv, options) {
|
|
|
1244
1251
|
return;
|
|
1245
1252
|
}
|
|
1246
1253
|
const etaText = etaMs !== undefined ? ` | ETA ${formatEtaText(etaMs)}` : "";
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
process.stdout.write(`\r${padded}`);
|
|
1250
|
-
lastProgressLine = padded;
|
|
1251
|
-
updateSpinnerMessage(`Running audit (Lighthouse) page ${completed}/${total}`);
|
|
1254
|
+
startAuditSpinner();
|
|
1255
|
+
updateSpinnerMessage(`Running audit (Lighthouse) page ${completed}/${total} — ${path} [${device}]${etaText}`);
|
|
1252
1256
|
},
|
|
1253
1257
|
});
|
|
1254
1258
|
}
|
|
@@ -347,6 +347,11 @@ async function createChromeSession(chromePort) {
|
|
|
347
347
|
"--safebrowsing-disable-auto-update",
|
|
348
348
|
"--password-store=basic",
|
|
349
349
|
"--use-mock-keychain",
|
|
350
|
+
// Stability flags for parallel runs
|
|
351
|
+
"--disable-hang-monitor",
|
|
352
|
+
"--disable-ipc-flooding-protection",
|
|
353
|
+
"--disable-domain-reliability",
|
|
354
|
+
"--disable-component-update",
|
|
350
355
|
],
|
|
351
356
|
});
|
|
352
357
|
return {
|
|
@@ -741,7 +746,14 @@ function isTransientLighthouseError(error) {
|
|
|
741
746
|
message.includes("LanternError") ||
|
|
742
747
|
message.includes("top level events") ||
|
|
743
748
|
message.includes("CDP") ||
|
|
744
|
-
message.includes("disconnected")
|
|
749
|
+
message.includes("disconnected") ||
|
|
750
|
+
// Additional transient errors for better retry handling
|
|
751
|
+
message.includes("WebSocket") ||
|
|
752
|
+
message.includes("webSocket") ||
|
|
753
|
+
message.includes("fetch failed") ||
|
|
754
|
+
message.includes("ECONNREFUSED") ||
|
|
755
|
+
message.includes("ECONNRESET") ||
|
|
756
|
+
message.includes("socket hang up"));
|
|
745
757
|
}
|
|
746
758
|
async function runSingleAuditWithRetry({ task, sessionRef, updateProgress, maxRetries, }) {
|
|
747
759
|
let attempt = 0;
|
|
@@ -13,7 +13,14 @@ function isTransientLighthouseError(error) {
|
|
|
13
13
|
message.includes("top level events") ||
|
|
14
14
|
message.includes("CDP") ||
|
|
15
15
|
message.includes("disconnected") ||
|
|
16
|
-
message.includes("ApexAuditor timeout")
|
|
16
|
+
message.includes("ApexAuditor timeout") ||
|
|
17
|
+
// Additional transient errors for better retry handling
|
|
18
|
+
message.includes("WebSocket") ||
|
|
19
|
+
message.includes("webSocket") ||
|
|
20
|
+
message.includes("fetch failed") ||
|
|
21
|
+
message.includes("ECONNREFUSED") ||
|
|
22
|
+
message.includes("ECONNRESET") ||
|
|
23
|
+
message.includes("socket hang up"));
|
|
17
24
|
}
|
|
18
25
|
async function createChromeSession() {
|
|
19
26
|
const userDataDir = await mkdtemp(join(tmpdir(), "apex-auditor-chrome-"));
|
|
@@ -39,6 +46,11 @@ async function createChromeSession() {
|
|
|
39
46
|
"--safebrowsing-disable-auto-update",
|
|
40
47
|
"--password-store=basic",
|
|
41
48
|
"--use-mock-keychain",
|
|
49
|
+
// Stability flags for parallel runs
|
|
50
|
+
"--disable-hang-monitor",
|
|
51
|
+
"--disable-ipc-flooding-protection",
|
|
52
|
+
"--disable-domain-reliability",
|
|
53
|
+
"--disable-component-update",
|
|
42
54
|
],
|
|
43
55
|
});
|
|
44
56
|
return {
|
|
@@ -212,8 +224,9 @@ function send(message) {
|
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
226
|
async function main() {
|
|
215
|
-
const maxRetries =
|
|
216
|
-
|
|
227
|
+
const maxRetries = 3;
|
|
228
|
+
// Recycle Chrome periodically to prevent memory leaks
|
|
229
|
+
const maxTasksPerChrome = 10;
|
|
217
230
|
const sessionRef = { session: await createChromeSession() };
|
|
218
231
|
let tasksSinceChromeStart = 0;
|
|
219
232
|
process.on("message", async (raw) => {
|
|
@@ -238,7 +251,7 @@ async function main() {
|
|
|
238
251
|
await sessionRef.session.close();
|
|
239
252
|
}
|
|
240
253
|
catch {
|
|
241
|
-
|
|
254
|
+
// Ignore close errors
|
|
242
255
|
}
|
|
243
256
|
sessionRef.session = await createChromeSession();
|
|
244
257
|
tasksSinceChromeStart = 0;
|
package/dist/route-detectors.js
CHANGED
|
@@ -10,6 +10,7 @@ const SOURCE_NUXT_PAGES = "nuxt-pages";
|
|
|
10
10
|
const SOURCE_REMIX = "remix-routes";
|
|
11
11
|
const SOURCE_SVELTEKIT = "sveltekit-routes";
|
|
12
12
|
const SOURCE_SPA = "spa-html";
|
|
13
|
+
const SOURCE_STATIC_HTML = "static-html";
|
|
13
14
|
const ROUTE_DETECTORS = [
|
|
14
15
|
createNextAppDetector(),
|
|
15
16
|
createNextPagesDetector(),
|
|
@@ -17,6 +18,7 @@ const ROUTE_DETECTORS = [
|
|
|
17
18
|
createRemixRoutesDetector(),
|
|
18
19
|
createSvelteKitRoutesDetector(),
|
|
19
20
|
createSpaHtmlDetector(),
|
|
21
|
+
createStaticHtmlDetector(),
|
|
20
22
|
];
|
|
21
23
|
export async function detectRoutes(options) {
|
|
22
24
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
@@ -134,6 +136,37 @@ function createSpaHtmlDetector() {
|
|
|
134
136
|
detect: async (options) => detectSpaRoutes(options.projectRoot, options.limit),
|
|
135
137
|
};
|
|
136
138
|
}
|
|
139
|
+
function createStaticHtmlDetector() {
|
|
140
|
+
return {
|
|
141
|
+
id: SOURCE_STATIC_HTML,
|
|
142
|
+
canDetect: async (options) => {
|
|
143
|
+
const roots = await findStaticHtmlRoots(options.projectRoot);
|
|
144
|
+
return roots.length > 0;
|
|
145
|
+
},
|
|
146
|
+
detect: async (options) => {
|
|
147
|
+
const roots = await findStaticHtmlRoots(options.projectRoot);
|
|
148
|
+
const allRoutes = [];
|
|
149
|
+
const seenPaths = new Set();
|
|
150
|
+
for (const root of roots) {
|
|
151
|
+
const routes = await detectStaticHtmlRoutes(root, options.limit - allRoutes.length);
|
|
152
|
+
for (const route of routes) {
|
|
153
|
+
if (allRoutes.length >= options.limit) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (seenPaths.has(route.path)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
seenPaths.add(route.path);
|
|
160
|
+
allRoutes.push(route);
|
|
161
|
+
}
|
|
162
|
+
if (allRoutes.length >= options.limit) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return allRoutes;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
137
170
|
async function findNextAppRoots(projectRoot) {
|
|
138
171
|
const roots = [];
|
|
139
172
|
const seen = new Set();
|
|
@@ -293,6 +326,28 @@ async function detectSpaRoutes(projectRoot, limit) {
|
|
|
293
326
|
const routes = extractRoutesFromHtml(html).slice(0, limit);
|
|
294
327
|
return routes.map((routePath) => ({ path: routePath, label: buildLabel(routePath), source: SOURCE_SPA }));
|
|
295
328
|
}
|
|
329
|
+
async function findStaticHtmlRoots(projectRoot) {
|
|
330
|
+
const candidates = [
|
|
331
|
+
join(projectRoot, "dist"),
|
|
332
|
+
join(projectRoot, "build"),
|
|
333
|
+
join(projectRoot, "out"),
|
|
334
|
+
join(projectRoot, "public"),
|
|
335
|
+
join(projectRoot, "src"),
|
|
336
|
+
];
|
|
337
|
+
const existing = [];
|
|
338
|
+
for (const candidate of candidates) {
|
|
339
|
+
if (await pathExists(candidate)) {
|
|
340
|
+
existing.push(candidate);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return existing;
|
|
344
|
+
}
|
|
345
|
+
async function detectStaticHtmlRoutes(root, limit) {
|
|
346
|
+
const files = await collectRouteFiles(root, limit, isStaticHtmlFile);
|
|
347
|
+
return files
|
|
348
|
+
.map((filePath) => buildRoute(filePath, root, formatStaticHtmlRoutePath, SOURCE_STATIC_HTML))
|
|
349
|
+
.filter((route) => !shouldSkipStaticHtmlRoute(route.path));
|
|
350
|
+
}
|
|
296
351
|
async function collectRouteFiles(root, limit, matcher) {
|
|
297
352
|
const stack = [root];
|
|
298
353
|
const files = [];
|
|
@@ -399,6 +454,16 @@ function hasAllowedExtension(path) {
|
|
|
399
454
|
function hasAllowedNuxtExtension(path) {
|
|
400
455
|
return NUXT_PAGE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
401
456
|
}
|
|
457
|
+
function isStaticHtmlFile(entry, relativePath) {
|
|
458
|
+
if (!entry.isFile()) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
const posixPath = normalisePath(relativePath);
|
|
462
|
+
if (!posixPath.toLowerCase().endsWith(".html")) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
return !posixPath.split("/").some((segment) => segment.startsWith("."));
|
|
466
|
+
}
|
|
402
467
|
function buildRoute(filePath, root, formatter, source) {
|
|
403
468
|
const relativePath = normalisePath(relative(root, filePath));
|
|
404
469
|
const routePath = formatter(relativePath);
|
|
@@ -505,6 +570,18 @@ function formatSvelteKitRoutePath(relativePath) {
|
|
|
505
570
|
}
|
|
506
571
|
return normaliseRoute(parts.join("/"));
|
|
507
572
|
}
|
|
573
|
+
function formatStaticHtmlRoutePath(relativePath) {
|
|
574
|
+
const cleanPath = relativePath.replace(/\\/g, "/");
|
|
575
|
+
const withoutExt = cleanPath.replace(/\.html$/i, "");
|
|
576
|
+
const withoutIndex = withoutExt.endsWith("/index") ? withoutExt.slice(0, -6) : withoutExt;
|
|
577
|
+
const normalized = withoutIndex.replace(/^\/+/, "");
|
|
578
|
+
return normalized.length === 0 ? "/" : normaliseRoute(normalized);
|
|
579
|
+
}
|
|
580
|
+
function shouldSkipStaticHtmlRoute(routePath) {
|
|
581
|
+
const normalized = routePath.toLowerCase();
|
|
582
|
+
const banned = ["/404", "/500", "/_error"];
|
|
583
|
+
return banned.includes(normalized);
|
|
584
|
+
}
|
|
508
585
|
function normaliseRoute(path) {
|
|
509
586
|
const trimmed = path.replace(/^\/+/, "");
|
|
510
587
|
if (trimmed.length === 0) {
|
package/dist/shell-cli.js
CHANGED
|
@@ -12,8 +12,11 @@ import { runLinksCli } from "./links-cli.js";
|
|
|
12
12
|
import { runMeasureCli } from "./measure-cli.js";
|
|
13
13
|
import { runWizardCli } from "./wizard-cli.js";
|
|
14
14
|
import { runCleanCli } from "./clean-cli.js";
|
|
15
|
+
import { runUninstallCli } from "./uninstall-cli.js";
|
|
16
|
+
import { loadConfig } from "./config.js";
|
|
15
17
|
import { pathExists } from "./fs-utils.js";
|
|
16
18
|
import { renderPanel } from "./ui/render-panel.js";
|
|
19
|
+
import { renderTable } from "./ui/render-table.js";
|
|
17
20
|
import { startSpinner, stopSpinner } from "./spinner.js";
|
|
18
21
|
import { UiTheme } from "./ui/ui-theme.js";
|
|
19
22
|
const SESSION_DIR_NAME = ".apex-auditor";
|
|
@@ -123,6 +126,148 @@ async function runConsoleAuditFromShell(session, args) {
|
|
|
123
126
|
async function writeJsonFile(absolutePath, value) {
|
|
124
127
|
await writeFile(absolutePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
125
128
|
}
|
|
129
|
+
async function loadCurrentConfig(configPath) {
|
|
130
|
+
const loaded = await loadConfig({ configPath });
|
|
131
|
+
return { absolutePath: loaded.configPath, config: loaded.config };
|
|
132
|
+
}
|
|
133
|
+
function formatPagesTable(pages) {
|
|
134
|
+
const headers = ["#", "path", "label", "devices"];
|
|
135
|
+
const rows = pages.map((p, index) => {
|
|
136
|
+
return [String(index + 1), p.path, p.label, p.devices.join(",")];
|
|
137
|
+
});
|
|
138
|
+
return renderTable({ headers, rows });
|
|
139
|
+
}
|
|
140
|
+
async function printConfiguredPages(session) {
|
|
141
|
+
const exists = await pathExists(session.configPath);
|
|
142
|
+
if (!exists) {
|
|
143
|
+
// eslint-disable-next-line no-console
|
|
144
|
+
console.log(`Config not found at ${session.configPath}. Run 'init' to create a config, or use 'config <path>' to point to one.`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const { absolutePath, config } = await loadCurrentConfig(session.configPath);
|
|
148
|
+
const lines = [];
|
|
149
|
+
lines.push(`${theme.dim("Config")}: ${absolutePath}`);
|
|
150
|
+
lines.push(`${theme.dim("Base URL")}: ${config.baseUrl}`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
lines.push(formatPagesTable(config.pages));
|
|
153
|
+
// eslint-disable-next-line no-console
|
|
154
|
+
console.log(renderPanel({ title: theme.bold("Pages"), lines }));
|
|
155
|
+
}
|
|
156
|
+
function parseDevices(raw) {
|
|
157
|
+
const parts = raw
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((p) => p.trim().toLowerCase())
|
|
160
|
+
.filter((p) => p.length > 0);
|
|
161
|
+
if (parts.length === 0) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const devices = [];
|
|
165
|
+
for (const part of parts) {
|
|
166
|
+
if (part === "mobile" || part === "desktop") {
|
|
167
|
+
devices.push(part);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return [...new Set(devices)];
|
|
173
|
+
}
|
|
174
|
+
function askLine(rl, question) {
|
|
175
|
+
return new Promise((resolvePromise) => {
|
|
176
|
+
rl.question(question, (value) => resolvePromise(value));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async function addPageInteractive(rl, session) {
|
|
180
|
+
const exists = await pathExists(session.configPath);
|
|
181
|
+
if (!exists) {
|
|
182
|
+
// eslint-disable-next-line no-console
|
|
183
|
+
console.log(`Config not found at ${session.configPath}. Run 'init' first.`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const { absolutePath, config } = await loadCurrentConfig(session.configPath);
|
|
187
|
+
// eslint-disable-next-line no-console
|
|
188
|
+
console.log(formatPagesTable(config.pages));
|
|
189
|
+
const rawPath = (await askLine(rl, "New page path (must start with /): ")).trim();
|
|
190
|
+
if (!rawPath.startsWith("/")) {
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.log("Cancelled: path must start with '/'.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const label = (await askLine(rl, "Label (optional): ")).trim() || rawPath;
|
|
196
|
+
const rawDevices = (await askLine(rl, "Devices (comma-separated: mobile,desktop) [mobile,desktop]: ")).trim();
|
|
197
|
+
const devices = parseDevices(rawDevices.length > 0 ? rawDevices : "mobile,desktop") ?? ["mobile", "desktop"];
|
|
198
|
+
const nextPages = [...config.pages, { path: rawPath, label, devices }];
|
|
199
|
+
const nextConfig = { ...config, pages: nextPages };
|
|
200
|
+
await writeJsonFile(absolutePath, nextConfig);
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.log(`Added page ${rawPath}.`);
|
|
203
|
+
}
|
|
204
|
+
async function removePageInteractive(rl, session, args) {
|
|
205
|
+
const exists = await pathExists(session.configPath);
|
|
206
|
+
if (!exists) {
|
|
207
|
+
// eslint-disable-next-line no-console
|
|
208
|
+
console.log(`Config not found at ${session.configPath}. Run 'init' first.`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const { absolutePath, config } = await loadCurrentConfig(session.configPath);
|
|
212
|
+
if (config.pages.length === 0) {
|
|
213
|
+
// eslint-disable-next-line no-console
|
|
214
|
+
console.log("No pages configured.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const byPath = args[0]?.trim();
|
|
218
|
+
const resolvedIndex = (() => {
|
|
219
|
+
if (byPath && byPath.startsWith("/")) {
|
|
220
|
+
const index = config.pages.findIndex((p) => p.path === byPath);
|
|
221
|
+
return index >= 0 ? index : undefined;
|
|
222
|
+
}
|
|
223
|
+
if (byPath && /^\d+$/.test(byPath)) {
|
|
224
|
+
const idx = parseInt(byPath, 10) - 1;
|
|
225
|
+
return idx >= 0 && idx < config.pages.length ? idx : undefined;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
})();
|
|
229
|
+
const indexToRemove = resolvedIndex ?? (() => {
|
|
230
|
+
// eslint-disable-next-line no-console
|
|
231
|
+
console.log(formatPagesTable(config.pages));
|
|
232
|
+
return undefined;
|
|
233
|
+
})();
|
|
234
|
+
const finalIndex = indexToRemove ?? (() => {
|
|
235
|
+
return undefined;
|
|
236
|
+
})();
|
|
237
|
+
let resolvedFinalIndex = finalIndex;
|
|
238
|
+
if (resolvedFinalIndex === undefined) {
|
|
239
|
+
const answer = (await askLine(rl, "Remove which page? Enter # or /path (blank to cancel): ")).trim();
|
|
240
|
+
if (answer.length === 0) {
|
|
241
|
+
// eslint-disable-next-line no-console
|
|
242
|
+
console.log("Cancelled.");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (answer.startsWith("/")) {
|
|
246
|
+
const idx = config.pages.findIndex((p) => p.path === answer);
|
|
247
|
+
resolvedFinalIndex = idx >= 0 ? idx : undefined;
|
|
248
|
+
}
|
|
249
|
+
else if (/^\d+$/.test(answer)) {
|
|
250
|
+
const idx = parseInt(answer, 10) - 1;
|
|
251
|
+
resolvedFinalIndex = idx >= 0 && idx < config.pages.length ? idx : undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (resolvedFinalIndex === undefined) {
|
|
255
|
+
// eslint-disable-next-line no-console
|
|
256
|
+
console.log("No matching page.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (config.pages.length <= 1) {
|
|
260
|
+
// eslint-disable-next-line no-console
|
|
261
|
+
console.log("Cancelled: config must contain at least one page.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const removed = config.pages[resolvedFinalIndex];
|
|
265
|
+
const nextPages = config.pages.filter((_p, i) => i !== resolvedFinalIndex);
|
|
266
|
+
const nextConfig = { ...config, pages: nextPages };
|
|
267
|
+
await writeJsonFile(absolutePath, nextConfig);
|
|
268
|
+
// eslint-disable-next-line no-console
|
|
269
|
+
console.log(`Removed page ${removed.path}.`);
|
|
270
|
+
}
|
|
126
271
|
function getSessionPaths(projectRoot) {
|
|
127
272
|
const dir = resolve(projectRoot, SESSION_DIR_NAME);
|
|
128
273
|
const file = join(dir, SESSION_FILE_NAME);
|
|
@@ -363,7 +508,12 @@ function printHelp() {
|
|
|
363
508
|
lines.push(`${theme.cyan("console")} Console errors + runtime exceptions audit (headless Chrome)`);
|
|
364
509
|
lines.push("");
|
|
365
510
|
lines.push(theme.bold("Other commands"));
|
|
511
|
+
lines.push(`${theme.cyan("pages")} Print configured pages/routes from the current config`);
|
|
512
|
+
lines.push(`${theme.cyan("routes")} Alias for pages`);
|
|
513
|
+
lines.push(`${theme.cyan("add-page")} Add a page to apex.config.json (interactive)`);
|
|
514
|
+
lines.push(`${theme.cyan("rm-page [#|/path]")} Remove a page from apex.config.json (interactive)`);
|
|
366
515
|
lines.push(`${theme.cyan("clean")} Remove ApexAuditor artifacts (reports/cache and optionally config)`);
|
|
516
|
+
lines.push(`${theme.cyan("uninstall")} Remove .apex-auditor and the current config file`);
|
|
367
517
|
lines.push(`${theme.cyan("open")} Open the last HTML report (or .apex-auditor/report.html)`);
|
|
368
518
|
lines.push(`${theme.cyan("diff")} Compare last run vs previous run (from this shell session)`);
|
|
369
519
|
lines.push(`${theme.cyan("preset <id>")} Set preset: default|overview|quick|accurate|fast`);
|
|
@@ -429,7 +579,12 @@ function createCompleter() {
|
|
|
429
579
|
"links",
|
|
430
580
|
"headers",
|
|
431
581
|
"console",
|
|
582
|
+
"pages",
|
|
583
|
+
"routes",
|
|
584
|
+
"add-page",
|
|
585
|
+
"rm-page",
|
|
432
586
|
"clean",
|
|
587
|
+
"uninstall",
|
|
433
588
|
"open",
|
|
434
589
|
"diff",
|
|
435
590
|
"preset",
|
|
@@ -440,6 +595,7 @@ function createCompleter() {
|
|
|
440
595
|
"exit",
|
|
441
596
|
"quit",
|
|
442
597
|
];
|
|
598
|
+
const uninstallFlags = ["--project-root", "--config-path", "--config", "--dry-run", "--yes", "-y", "--json"];
|
|
443
599
|
const presets = ["default", "overview", "quick", "accurate", "fast"];
|
|
444
600
|
const onOff = ["on", "off"];
|
|
445
601
|
const buildIdModes = ["auto", "manual"];
|
|
@@ -501,6 +657,9 @@ function createCompleter() {
|
|
|
501
657
|
if (command === "clean") {
|
|
502
658
|
return [filterStartsWith(cleanFlags, fragment), rawLine];
|
|
503
659
|
}
|
|
660
|
+
if (command === "uninstall") {
|
|
661
|
+
return [filterStartsWith(uninstallFlags, fragment), rawLine];
|
|
662
|
+
}
|
|
504
663
|
return [[], rawLine];
|
|
505
664
|
};
|
|
506
665
|
return (line) => {
|
|
@@ -683,6 +842,10 @@ async function handleShellCommand(projectRoot, session, command) {
|
|
|
683
842
|
if (command.id === "exit" || command.id === "quit") {
|
|
684
843
|
return { session, shouldExit: true };
|
|
685
844
|
}
|
|
845
|
+
if (command.id === "pages" || command.id === "routes") {
|
|
846
|
+
await printConfiguredPages(session);
|
|
847
|
+
return { session, shouldExit: false };
|
|
848
|
+
}
|
|
686
849
|
if (command.id === "audit") {
|
|
687
850
|
const nextSession = await runAuditFromShell(projectRoot, session, command.args);
|
|
688
851
|
return { session: nextSession, shouldExit: false };
|
|
@@ -716,6 +879,11 @@ async function handleShellCommand(projectRoot, session, command) {
|
|
|
716
879
|
await runCleanCli(argv);
|
|
717
880
|
return { session, shouldExit: false };
|
|
718
881
|
}
|
|
882
|
+
if (command.id === "uninstall") {
|
|
883
|
+
const argv = ["node", "apex-auditor", "--project-root", projectRoot, "--config-path", session.configPath, ...command.args];
|
|
884
|
+
await runUninstallCli(argv);
|
|
885
|
+
return { session, shouldExit: false };
|
|
886
|
+
}
|
|
719
887
|
if (command.id === "init") {
|
|
720
888
|
// eslint-disable-next-line no-console
|
|
721
889
|
console.log("Starting config wizard...");
|
|
@@ -788,127 +956,170 @@ export async function runShellCli(argv) {
|
|
|
788
956
|
void argv;
|
|
789
957
|
const projectRoot = process.cwd();
|
|
790
958
|
let session = await loadSession(projectRoot);
|
|
791
|
-
|
|
792
|
-
let
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
rl.off("SIGINT", onSigint);
|
|
811
|
-
try {
|
|
812
|
-
rl.prompt(true);
|
|
813
|
-
}
|
|
814
|
-
catch {
|
|
815
|
-
// ignore
|
|
816
|
-
}
|
|
817
|
-
if (typeof onLine === "function") {
|
|
818
|
-
rl.off("line", onLine);
|
|
819
|
-
}
|
|
820
|
-
try {
|
|
821
|
-
await runWizardCli(["node", "apex-auditor"]);
|
|
959
|
+
let printedHome = false;
|
|
960
|
+
let shouldExitShell = false;
|
|
961
|
+
while (!shouldExitShell) {
|
|
962
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer: createCompleter() });
|
|
963
|
+
let rlClosed = false;
|
|
964
|
+
let shouldExit = false;
|
|
965
|
+
let suppressInput = false;
|
|
966
|
+
rl.on("close", () => {
|
|
967
|
+
rlClosed = true;
|
|
968
|
+
});
|
|
969
|
+
const onSigint = () => {
|
|
970
|
+
shouldExit = true;
|
|
971
|
+
rl.close();
|
|
972
|
+
};
|
|
973
|
+
rl.on("SIGINT", onSigint);
|
|
974
|
+
if (!printedHome) {
|
|
975
|
+
const version = await readCliVersion();
|
|
976
|
+
printHomeScreen({ version, session });
|
|
977
|
+
printedHome = true;
|
|
822
978
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
979
|
+
rl.setPrompt(buildPrompt(session));
|
|
980
|
+
rl.prompt();
|
|
981
|
+
let onLine;
|
|
982
|
+
const runWizardInShell = async () => {
|
|
983
|
+
const previousPrompt = buildPrompt(session);
|
|
984
|
+
rl.pause();
|
|
985
|
+
rl.setPrompt("");
|
|
986
|
+
rl.off("SIGINT", onSigint);
|
|
987
|
+
try {
|
|
988
|
+
rl.prompt(true);
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
// ignore
|
|
827
992
|
}
|
|
828
993
|
if (typeof onLine === "function") {
|
|
829
|
-
rl.
|
|
994
|
+
rl.off("line", onLine);
|
|
830
995
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
996
|
+
try {
|
|
997
|
+
await runWizardCli(["node", "apex-auditor"]);
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
rl.on("SIGINT", onSigint);
|
|
1001
|
+
if (rlClosed) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (typeof onLine === "function") {
|
|
1005
|
+
rl.on("line", onLine);
|
|
1006
|
+
}
|
|
1007
|
+
rl.setPrompt(previousPrompt);
|
|
1008
|
+
rl.resume();
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
onLine = async (line) => {
|
|
1012
|
+
if (suppressInput) {
|
|
848
1013
|
return;
|
|
849
1014
|
}
|
|
850
|
-
command =
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const exists = await pathExists(session.configPath);
|
|
854
|
-
if (!exists) {
|
|
1015
|
+
let command = parseShellCommand(line);
|
|
1016
|
+
const isEditCommand = command.id === "add-page" || command.id === "rm-page";
|
|
1017
|
+
if (isEditCommand && process.stdin.isTTY) {
|
|
855
1018
|
suppressInput = true;
|
|
856
1019
|
rl.pause();
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const text = answer.trim().toLowerCase();
|
|
861
|
-
const accepted = text.length === 0 || text === "y" || text === "yes";
|
|
862
|
-
if (accepted) {
|
|
863
|
-
// eslint-disable-next-line no-console
|
|
864
|
-
console.log("Starting config wizard...");
|
|
865
|
-
try {
|
|
866
|
-
await runWizardInShell();
|
|
867
|
-
// eslint-disable-next-line no-console
|
|
868
|
-
console.log(`Ready. Next: ${theme.cyan("audit")}.`);
|
|
1020
|
+
try {
|
|
1021
|
+
if (command.id === "add-page") {
|
|
1022
|
+
await addPageInteractive(rl, session);
|
|
869
1023
|
}
|
|
870
|
-
|
|
871
|
-
|
|
1024
|
+
else {
|
|
1025
|
+
await removePageInteractive(rl, session, command.args);
|
|
872
1026
|
}
|
|
873
1027
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1028
|
+
finally {
|
|
1029
|
+
if (!rlClosed) {
|
|
1030
|
+
rl.resume();
|
|
1031
|
+
suppressInput = false;
|
|
1032
|
+
rl.setPrompt(buildPrompt(session));
|
|
1033
|
+
rl.prompt();
|
|
1034
|
+
}
|
|
877
1035
|
}
|
|
878
|
-
rl.resume();
|
|
879
|
-
suppressInput = false;
|
|
880
|
-
rl.setPrompt(buildPrompt(session));
|
|
881
|
-
rl.prompt();
|
|
882
1036
|
return;
|
|
883
1037
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1038
|
+
if (command.id === "clean" && !command.args.includes("--yes") && !command.args.includes("-y") && process.stdin.isTTY) {
|
|
1039
|
+
const targets = resolveCleanTargets({ projectRoot, session, args: command.args });
|
|
1040
|
+
const ok = await confirmCleanInShell({ rl, targets });
|
|
1041
|
+
if (!ok) {
|
|
1042
|
+
// eslint-disable-next-line no-console
|
|
1043
|
+
console.log("Cancelled.");
|
|
1044
|
+
rl.setPrompt(buildPrompt(session));
|
|
1045
|
+
rl.prompt();
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
command = { ...command, args: [...command.args, "--yes"] };
|
|
1049
|
+
}
|
|
1050
|
+
if (command.id === "audit" && process.stdin.isTTY) {
|
|
1051
|
+
const exists = await pathExists(session.configPath);
|
|
1052
|
+
if (!exists) {
|
|
1053
|
+
suppressInput = true;
|
|
1054
|
+
rl.pause();
|
|
1055
|
+
const answer = await new Promise((resolvePromise) => {
|
|
1056
|
+
rl.question(`Config not found at ${session.configPath}. Run the init wizard now? (Y/n) `, (value) => resolvePromise(value));
|
|
1057
|
+
});
|
|
1058
|
+
const text = answer.trim().toLowerCase();
|
|
1059
|
+
const accepted = text.length === 0 || text === "y" || text === "yes";
|
|
1060
|
+
if (accepted) {
|
|
1061
|
+
// eslint-disable-next-line no-console
|
|
1062
|
+
console.log("Starting config wizard...");
|
|
1063
|
+
try {
|
|
1064
|
+
await runWizardInShell();
|
|
1065
|
+
// eslint-disable-next-line no-console
|
|
1066
|
+
console.log(`Ready. Next: ${theme.cyan("audit")}.`);
|
|
1067
|
+
}
|
|
1068
|
+
finally {
|
|
1069
|
+
// no-op
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
// eslint-disable-next-line no-console
|
|
1074
|
+
console.log(`Config required. Create one with ${theme.cyan("init")} or point to an existing file with ${theme.cyan("config <path>")}.`);
|
|
1075
|
+
}
|
|
1076
|
+
if (!rlClosed) {
|
|
1077
|
+
rl.resume();
|
|
1078
|
+
suppressInput = false;
|
|
1079
|
+
rl.setPrompt(buildPrompt(session));
|
|
1080
|
+
rl.prompt();
|
|
1081
|
+
}
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (command.id === "init") {
|
|
1086
|
+
// eslint-disable-next-line no-console
|
|
1087
|
+
console.log("Starting config wizard...");
|
|
1088
|
+
await runWizardInShell();
|
|
1089
|
+
// eslint-disable-next-line no-console
|
|
1090
|
+
console.log(`Ready. Next: ${theme.cyan("measure")} or ${theme.cyan("audit")}.`);
|
|
1091
|
+
// Force readline recreation by closing current instance - this ensures
|
|
1092
|
+
// the shell stays alive after the wizard completes even if prompts closed stdin
|
|
900
1093
|
rl.close();
|
|
901
1094
|
return;
|
|
902
1095
|
}
|
|
1096
|
+
rl.pause();
|
|
1097
|
+
try {
|
|
1098
|
+
const result = await handleShellCommand(projectRoot, session, command);
|
|
1099
|
+
session = result.session;
|
|
1100
|
+
if (result.shouldExit) {
|
|
1101
|
+
shouldExit = true;
|
|
1102
|
+
rl.close();
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
finally {
|
|
1107
|
+
if (!rlClosed) {
|
|
1108
|
+
rl.resume();
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (!rlClosed) {
|
|
1112
|
+
rl.setPrompt(buildPrompt(session));
|
|
1113
|
+
rl.prompt();
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
rl.on("line", onLine);
|
|
1117
|
+
await new Promise((resolvePromise) => {
|
|
1118
|
+
rl.on("close", () => resolvePromise());
|
|
1119
|
+
});
|
|
1120
|
+
if (shouldExit) {
|
|
1121
|
+
shouldExitShell = true;
|
|
1122
|
+
continue;
|
|
903
1123
|
}
|
|
904
|
-
|
|
905
|
-
rl.resume();
|
|
906
|
-
}
|
|
907
|
-
rl.setPrompt(buildPrompt(session));
|
|
908
|
-
rl.prompt();
|
|
909
|
-
};
|
|
910
|
-
rl.on("line", onLine);
|
|
911
|
-
await new Promise((resolvePromise) => {
|
|
912
|
-
rl.on("close", () => resolvePromise());
|
|
913
|
-
});
|
|
1124
|
+
}
|
|
914
1125
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { pathExists } from "./fs-utils.js";
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
let projectRoot = process.cwd();
|
|
7
|
+
let configPath = "apex.config.json";
|
|
8
|
+
let dryRun = false;
|
|
9
|
+
let yes = false;
|
|
10
|
+
let jsonOutput = false;
|
|
11
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
12
|
+
const arg = argv[i] ?? "";
|
|
13
|
+
if (arg === "--project-root" && i + 1 < argv.length) {
|
|
14
|
+
projectRoot = argv[i + 1] ?? projectRoot;
|
|
15
|
+
i += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if ((arg === "--config-path" || arg === "--config") && i + 1 < argv.length) {
|
|
19
|
+
configPath = argv[i + 1] ?? configPath;
|
|
20
|
+
i += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (arg === "--dry-run") {
|
|
24
|
+
dryRun = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (arg === "--yes" || arg === "-y") {
|
|
28
|
+
yes = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (arg === "--json") {
|
|
32
|
+
jsonOutput = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
projectRoot: resolve(projectRoot),
|
|
38
|
+
configPath: resolve(projectRoot, configPath),
|
|
39
|
+
dryRun,
|
|
40
|
+
yes,
|
|
41
|
+
jsonOutput,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function confirmPrompt(question) {
|
|
45
|
+
if (!process.stdin.isTTY) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
process.stdin.resume();
|
|
49
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
50
|
+
try {
|
|
51
|
+
const answer = await new Promise((resolvePromise) => {
|
|
52
|
+
rl.question(question, (value) => resolvePromise(value));
|
|
53
|
+
});
|
|
54
|
+
const text = answer.trim().toLowerCase();
|
|
55
|
+
return text === "y" || text === "yes";
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rl.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function detectPackageManager(projectRoot) {
|
|
62
|
+
const pnpmLock = await pathExists(resolve(projectRoot, "pnpm-lock.yaml"));
|
|
63
|
+
if (pnpmLock) {
|
|
64
|
+
return "pnpm";
|
|
65
|
+
}
|
|
66
|
+
const bunLock = await pathExists(resolve(projectRoot, "bun.lockb"));
|
|
67
|
+
if (bunLock) {
|
|
68
|
+
return "bun";
|
|
69
|
+
}
|
|
70
|
+
const yarnLock = await pathExists(resolve(projectRoot, "yarn.lock"));
|
|
71
|
+
if (yarnLock) {
|
|
72
|
+
return "yarn";
|
|
73
|
+
}
|
|
74
|
+
const npmLock = await pathExists(resolve(projectRoot, "package-lock.json"));
|
|
75
|
+
if (npmLock) {
|
|
76
|
+
return "npm";
|
|
77
|
+
}
|
|
78
|
+
return "unknown";
|
|
79
|
+
}
|
|
80
|
+
function buildDependencyUninstallCommand(packageManager) {
|
|
81
|
+
const packageName = "apex-auditor";
|
|
82
|
+
if (packageManager === "pnpm") {
|
|
83
|
+
return `pnpm remove ${packageName}`;
|
|
84
|
+
}
|
|
85
|
+
if (packageManager === "yarn") {
|
|
86
|
+
return `yarn remove ${packageName}`;
|
|
87
|
+
}
|
|
88
|
+
if (packageManager === "bun") {
|
|
89
|
+
return `bun remove ${packageName}`;
|
|
90
|
+
}
|
|
91
|
+
if (packageManager === "npm") {
|
|
92
|
+
return `npm uninstall ${packageName}`;
|
|
93
|
+
}
|
|
94
|
+
return `npm uninstall ${packageName}`;
|
|
95
|
+
}
|
|
96
|
+
function buildPlan(args) {
|
|
97
|
+
const actions = [];
|
|
98
|
+
actions.push({ kind: "rm", path: resolve(args.projectRoot, ".apex-auditor"), existsByAssumption: true });
|
|
99
|
+
actions.push({ kind: "rm", path: args.configPath, existsByAssumption: true });
|
|
100
|
+
return actions;
|
|
101
|
+
}
|
|
102
|
+
async function executePlan(plan, dryRun) {
|
|
103
|
+
if (dryRun) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const executed = [];
|
|
107
|
+
for (const action of plan) {
|
|
108
|
+
if (action.kind === "rm") {
|
|
109
|
+
await rm(action.path, { recursive: true, force: true });
|
|
110
|
+
executed.push(action);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return executed;
|
|
114
|
+
}
|
|
115
|
+
export async function runUninstallCli(argv) {
|
|
116
|
+
const startedAtMs = Date.now();
|
|
117
|
+
const args = parseArgs(argv);
|
|
118
|
+
const planned = buildPlan(args);
|
|
119
|
+
const packageManager = await detectPackageManager(args.projectRoot);
|
|
120
|
+
const dependencyUninstallCommand = buildDependencyUninstallCommand(packageManager);
|
|
121
|
+
if (!args.yes && process.stdin.isTTY) {
|
|
122
|
+
const targets = planned.map((p) => p.path).join("\n");
|
|
123
|
+
const ok = await confirmPrompt(`This will remove:\n${targets}\nContinue? (y/N) `);
|
|
124
|
+
if (!ok) {
|
|
125
|
+
console.log("Cancelled.");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const executed = await executePlan(planned, args.dryRun);
|
|
130
|
+
const completedAtMs = Date.now();
|
|
131
|
+
const report = {
|
|
132
|
+
meta: {
|
|
133
|
+
projectRoot: args.projectRoot,
|
|
134
|
+
configPath: args.configPath,
|
|
135
|
+
dryRun: args.dryRun,
|
|
136
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
137
|
+
completedAt: new Date(completedAtMs).toISOString(),
|
|
138
|
+
elapsedMs: completedAtMs - startedAtMs,
|
|
139
|
+
},
|
|
140
|
+
planned,
|
|
141
|
+
executed,
|
|
142
|
+
packageManager,
|
|
143
|
+
dependencyUninstallCommand,
|
|
144
|
+
};
|
|
145
|
+
if (args.jsonOutput) {
|
|
146
|
+
console.log(JSON.stringify(report, null, 2));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (args.dryRun) {
|
|
150
|
+
console.log(`Planned removals: ${planned.length} (dry-run).`);
|
|
151
|
+
console.log(`Dependency uninstall (optional): ${dependencyUninstallCommand}`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
console.log(`Removed: ${executed.length}/${planned.length}.`);
|
|
155
|
+
console.log(`Dependency uninstall (optional): ${dependencyUninstallCommand}`);
|
|
156
|
+
}
|
package/dist/wizard-cli.js
CHANGED
|
@@ -82,13 +82,13 @@ const pageQuestions = [
|
|
|
82
82
|
const addFirstPageQuestion = {
|
|
83
83
|
type: "confirm",
|
|
84
84
|
name: "value",
|
|
85
|
-
message: "Add
|
|
85
|
+
message: "No pages were auto-detected. Add a page to audit now? (You can always edit apex.config.json later)",
|
|
86
86
|
initial: true,
|
|
87
87
|
};
|
|
88
88
|
const addMorePagesQuestion = {
|
|
89
89
|
type: "confirm",
|
|
90
90
|
name: "value",
|
|
91
|
-
message: "Add another page to audit?",
|
|
91
|
+
message: "Add another page to audit? (Optional — you can edit apex.config.json later)",
|
|
92
92
|
initial: false,
|
|
93
93
|
};
|
|
94
94
|
const projectRootQuestion = {
|
|
@@ -107,8 +107,62 @@ const detectorChoiceQuestion = {
|
|
|
107
107
|
{ title: "Remix", value: "remix-routes" },
|
|
108
108
|
{ title: "SvelteKit", value: "sveltekit-routes" },
|
|
109
109
|
{ title: "SPA Crawl", value: "spa-html" },
|
|
110
|
+
{ title: "Static HTML (dist/build/out/public/src)", value: "static-html" },
|
|
110
111
|
],
|
|
111
112
|
};
|
|
113
|
+
const routeFilterConfirmQuestion = {
|
|
114
|
+
type: "confirm",
|
|
115
|
+
name: "value",
|
|
116
|
+
message: "Filter detected routes with include/exclude patterns?",
|
|
117
|
+
initial: false,
|
|
118
|
+
};
|
|
119
|
+
const routeFilterQuestions = [
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
name: "include",
|
|
123
|
+
message: "Include patterns (comma-separated, optional)",
|
|
124
|
+
initial: "",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: "text",
|
|
128
|
+
name: "exclude",
|
|
129
|
+
message: "Exclude patterns (comma-separated, optional)",
|
|
130
|
+
initial: "",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
const ROUTE_FILTER_DEFAULT_ON_THRESHOLD = 15;
|
|
134
|
+
function buildSuggestedExcludePatterns(profile) {
|
|
135
|
+
if (profile === "next") {
|
|
136
|
+
return ["/_next/*", "/api/*"];
|
|
137
|
+
}
|
|
138
|
+
if (profile === "nuxt") {
|
|
139
|
+
return ["/__nuxt/*", "/api/*"];
|
|
140
|
+
}
|
|
141
|
+
if (profile === "sveltekit") {
|
|
142
|
+
return ["/__data.json*", "/api/*"];
|
|
143
|
+
}
|
|
144
|
+
if (profile === "remix") {
|
|
145
|
+
return ["/api/*"];
|
|
146
|
+
}
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
function buildRouteFilterQuestions(params) {
|
|
150
|
+
const excludeInitial = params.suggestedExclude.join(",");
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
type: "text",
|
|
154
|
+
name: "include",
|
|
155
|
+
message: "Include patterns (comma-separated, optional)",
|
|
156
|
+
initial: "",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
type: "text",
|
|
160
|
+
name: "exclude",
|
|
161
|
+
message: "Exclude patterns (comma-separated, optional)",
|
|
162
|
+
initial: excludeInitial,
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
}
|
|
112
166
|
function handleCancel() {
|
|
113
167
|
console.log("Wizard cancelled. No config written.");
|
|
114
168
|
throw new WizardAbortError("cancelled");
|
|
@@ -124,6 +178,42 @@ async function collectBaseAnswers() {
|
|
|
124
178
|
query: answers.query,
|
|
125
179
|
};
|
|
126
180
|
}
|
|
181
|
+
function tryParseLocalDevServer(baseUrl) {
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = new URL(baseUrl);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const host = parsed.hostname;
|
|
190
|
+
if (host !== "localhost" && host !== "127.0.0.1") {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const port = parsed.port.length > 0 ? Number(parsed.port) : NaN;
|
|
194
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
return { host, port };
|
|
198
|
+
}
|
|
199
|
+
async function collectBaseAnswersWithSafety() {
|
|
200
|
+
while (true) {
|
|
201
|
+
const answers = await collectBaseAnswers();
|
|
202
|
+
const info = tryParseLocalDevServer(answers.baseUrl);
|
|
203
|
+
if (!info) {
|
|
204
|
+
return answers;
|
|
205
|
+
}
|
|
206
|
+
const confirmed = await ask({
|
|
207
|
+
type: "confirm",
|
|
208
|
+
name: "value",
|
|
209
|
+
message: `Base URL is ${answers.baseUrl}. Make sure the dev server is running for this project on port ${info.port} to avoid config conflicts when multiple projects are open. Continue?`,
|
|
210
|
+
initial: true,
|
|
211
|
+
});
|
|
212
|
+
if (confirmed.value) {
|
|
213
|
+
return answers;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
127
217
|
function profileDisplayName(profile) {
|
|
128
218
|
switch (profile) {
|
|
129
219
|
case "next":
|
|
@@ -358,8 +448,8 @@ async function collectSinglePage() {
|
|
|
358
448
|
}
|
|
359
449
|
async function collectPages(initialPages) {
|
|
360
450
|
const pages = [...initialPages];
|
|
361
|
-
// If we already detected pages, return them immediately for speed.
|
|
362
451
|
if (pages.length > 0) {
|
|
452
|
+
console.log("Tip: You can add/remove pages later by editing apex.config.json or re-running init.");
|
|
363
453
|
return pages;
|
|
364
454
|
}
|
|
365
455
|
while (true) {
|
|
@@ -398,32 +488,91 @@ async function maybeDetectPages(params) {
|
|
|
398
488
|
console.log("No routes detected. Add pages manually.");
|
|
399
489
|
return [];
|
|
400
490
|
}
|
|
401
|
-
|
|
491
|
+
console.log(`Detected ${combined.length} route(s) using auto-discovery.`);
|
|
492
|
+
const suggestedExclude = buildSuggestedExcludePatterns(params.profile);
|
|
493
|
+
const initialFilter = combined.length >= ROUTE_FILTER_DEFAULT_ON_THRESHOLD || suggestedExclude.length > 0;
|
|
494
|
+
const confirmQuestion = { ...routeFilterConfirmQuestion, initial: initialFilter };
|
|
495
|
+
const shouldFilter = await ask(confirmQuestion);
|
|
496
|
+
const filtered = shouldFilter.value
|
|
497
|
+
? await filterDetectedRoutes({ routes: combined, profile: params.profile, suggestedExclude })
|
|
498
|
+
: combined;
|
|
499
|
+
if (filtered.length === 0) {
|
|
500
|
+
console.log("No routes remain after filtering. Add pages manually.");
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
if (filtered.length !== combined.length) {
|
|
504
|
+
console.log(`Filtered routes: ${filtered.length}/${combined.length}.`);
|
|
505
|
+
}
|
|
506
|
+
return selectDetectedRoutes(filtered);
|
|
507
|
+
}
|
|
508
|
+
async function filterDetectedRoutes(params) {
|
|
509
|
+
const questions = buildRouteFilterQuestions({ profile: params.profile, suggestedExclude: params.suggestedExclude });
|
|
510
|
+
const answers = await ask(questions);
|
|
511
|
+
const includePatterns = splitPatterns(answers.include);
|
|
512
|
+
const excludePatterns = splitPatterns(answers.exclude);
|
|
513
|
+
const includeRegexes = includePatterns.map((pattern) => compileGlob(pattern));
|
|
514
|
+
const excludeRegexes = excludePatterns.map((pattern) => compileGlob(pattern));
|
|
515
|
+
return params.routes.filter((route) => {
|
|
516
|
+
const included = includeRegexes.length === 0 ? true : includeRegexes.some((regex) => regex.test(route.path));
|
|
517
|
+
const excluded = excludeRegexes.some((regex) => regex.test(route.path));
|
|
518
|
+
return included && !excluded;
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
function splitPatterns(raw) {
|
|
522
|
+
return raw
|
|
523
|
+
.split(",")
|
|
524
|
+
.map((part) => part.trim())
|
|
525
|
+
.filter((part) => part.length > 0);
|
|
526
|
+
}
|
|
527
|
+
function compileGlob(pattern) {
|
|
528
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
529
|
+
const wildcarded = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
530
|
+
return new RegExp(`^${wildcarded}$`);
|
|
402
531
|
}
|
|
403
532
|
async function chooseDetectionRoot({ profile, repoRoot, }) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
533
|
+
const candidates = await findMonorepoCandidates(repoRoot);
|
|
534
|
+
const matching = candidates.filter((candidate) => candidate.profile === profile);
|
|
535
|
+
if (matching.length === 0) {
|
|
536
|
+
if (profile !== "next") {
|
|
537
|
+
return repoRoot;
|
|
538
|
+
}
|
|
539
|
+
const projects = await discoverNextProjects({ repoRoot });
|
|
540
|
+
if (projects.length === 0) {
|
|
541
|
+
return repoRoot;
|
|
542
|
+
}
|
|
543
|
+
if (projects.length === 1) {
|
|
544
|
+
const onlyProject = projects[0];
|
|
545
|
+
console.log(`Detected Next.js app at ${onlyProject.root}.`);
|
|
546
|
+
return onlyProject.root;
|
|
547
|
+
}
|
|
548
|
+
const choices = projects.map((project) => ({
|
|
549
|
+
title: `${project.name} (${project.root})`,
|
|
550
|
+
value: project.root,
|
|
551
|
+
}));
|
|
552
|
+
const answer = await ask({
|
|
553
|
+
type: "select",
|
|
554
|
+
name: "projectRoot",
|
|
555
|
+
message: "Multiple Next.js apps found. Which one do you want to audit?",
|
|
556
|
+
choices,
|
|
557
|
+
});
|
|
558
|
+
return answer.projectRoot ?? repoRoot;
|
|
410
559
|
}
|
|
411
|
-
if (
|
|
412
|
-
const
|
|
413
|
-
console.log(`Detected
|
|
414
|
-
return
|
|
560
|
+
if (matching.length === 1) {
|
|
561
|
+
const only = matching[0];
|
|
562
|
+
console.log(`Detected ${profileDisplayName(profile)} app at ${only.root}.`);
|
|
563
|
+
return only.root;
|
|
415
564
|
}
|
|
416
|
-
const choices =
|
|
417
|
-
title: `${
|
|
418
|
-
value:
|
|
565
|
+
const choices = matching.map((candidate) => ({
|
|
566
|
+
title: `${candidate.name} (${candidate.root}) - ${profileDisplayName(candidate.profile)}`,
|
|
567
|
+
value: candidate.root,
|
|
419
568
|
}));
|
|
420
569
|
const answer = await ask({
|
|
421
570
|
type: "select",
|
|
422
571
|
name: "projectRoot",
|
|
423
|
-
message:
|
|
572
|
+
message: `Multiple ${profileDisplayName(profile)} apps found. Which one do you want to audit?`,
|
|
424
573
|
choices,
|
|
425
574
|
});
|
|
426
|
-
return answer.projectRoot ?? repoRoot;
|
|
575
|
+
return answer.projectRoot ?? matching[0]?.root ?? repoRoot;
|
|
427
576
|
}
|
|
428
577
|
async function selectDetector(profile) {
|
|
429
578
|
const preset = PROFILE_TO_DETECTOR[profile];
|
|
@@ -466,7 +615,7 @@ function convertRouteToPage(route) {
|
|
|
466
615
|
};
|
|
467
616
|
}
|
|
468
617
|
async function buildConfig() {
|
|
469
|
-
const baseAnswers = await
|
|
618
|
+
const baseAnswers = await collectBaseAnswersWithSafety();
|
|
470
619
|
const projectRootAnswer = await ask(projectRootQuestion);
|
|
471
620
|
const initialRepoRoot = resolve(projectRootAnswer.projectRoot);
|
|
472
621
|
const resolved = await resolveWizardProjectRoot(initialRepoRoot);
|