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 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: overviewSample.config,
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(boxifyWithSeparators(tipLines));
1169
- printDivider();
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: overviewSample.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
- const message = `* Running audit (Lighthouse) page ${completed}/${total} — ${path} [${device}]${etaText}`;
1248
- const padded = message.padEnd(lastProgressLine?.length ?? message.length, " ");
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 = 2;
216
- const maxTasksPerChrome = 20;
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
- return;
254
+ // Ignore close errors
242
255
  }
243
256
  sessionRef.session = await createChromeSession();
244
257
  tasksSinceChromeStart = 0;
@@ -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
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer: createCompleter() });
792
- let rlClosed = false;
793
- rl.on("close", () => {
794
- rlClosed = true;
795
- });
796
- const onSigint = () => {
797
- rl.close();
798
- };
799
- rl.on("SIGINT", onSigint);
800
- let suppressInput = false;
801
- const version = await readCliVersion();
802
- printHomeScreen({ version, session });
803
- rl.setPrompt(buildPrompt(session));
804
- rl.prompt();
805
- let onLine;
806
- const runWizardInShell = async () => {
807
- const previousPrompt = buildPrompt(session);
808
- rl.pause();
809
- rl.setPrompt("");
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
- finally {
824
- rl.on("SIGINT", onSigint);
825
- if (rlClosed) {
826
- return;
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.on("line", onLine);
994
+ rl.off("line", onLine);
830
995
  }
831
- rl.setPrompt(previousPrompt);
832
- rl.resume();
833
- }
834
- };
835
- onLine = async (line) => {
836
- if (suppressInput) {
837
- return;
838
- }
839
- let command = parseShellCommand(line);
840
- if (command.id === "clean" && !command.args.includes("--yes") && !command.args.includes("-y") && process.stdin.isTTY) {
841
- const targets = resolveCleanTargets({ projectRoot, session, args: command.args });
842
- const ok = await confirmCleanInShell({ rl, targets });
843
- if (!ok) {
844
- // eslint-disable-next-line no-console
845
- console.log("Cancelled.");
846
- rl.setPrompt(buildPrompt(session));
847
- rl.prompt();
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 = { ...command, args: [...command.args, "--yes"] };
851
- }
852
- if (command.id === "audit" && process.stdin.isTTY) {
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
- const answer = await new Promise((resolvePromise) => {
858
- rl.question(`Config not found at ${session.configPath}. Run the init wizard now? (Y/n) `, (value) => resolvePromise(value));
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
- finally {
871
- // no-op
1024
+ else {
1025
+ await removePageInteractive(rl, session, command.args);
872
1026
  }
873
1027
  }
874
- else {
875
- // eslint-disable-next-line no-console
876
- console.log(`Config required. Create one with ${theme.cyan("init")} or point to an existing file with ${theme.cyan("config <path>")}.`);
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
- if (command.id === "init") {
886
- // eslint-disable-next-line no-console
887
- console.log("Starting config wizard...");
888
- await runWizardInShell();
889
- // eslint-disable-next-line no-console
890
- console.log(`Ready. Next: ${theme.cyan("measure")} or ${theme.cyan("audit")}.`);
891
- rl.setPrompt(buildPrompt(session));
892
- rl.prompt();
893
- return;
894
- }
895
- rl.pause();
896
- try {
897
- const result = await handleShellCommand(projectRoot, session, command);
898
- session = result.session;
899
- if (result.shouldExit) {
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
- finally {
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
+ }
@@ -82,13 +82,13 @@ const pageQuestions = [
82
82
  const addFirstPageQuestion = {
83
83
  type: "confirm",
84
84
  name: "value",
85
- message: "Add your first page to audit?",
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
- return selectDetectedRoutes(combined);
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
- if (profile !== "next") {
405
- return repoRoot;
406
- }
407
- const projects = await discoverNextProjects({ repoRoot });
408
- if (projects.length === 0) {
409
- return repoRoot;
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 (projects.length === 1) {
412
- const onlyProject = projects[0];
413
- console.log(`Detected Next.js app at ${onlyProject.root}.`);
414
- return onlyProject.root;
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 = projects.map((project) => ({
417
- title: `${project.name} (${project.root})`,
418
- value: project.root,
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: "Multiple Next.js apps found. Which one do you want to audit?",
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 collectBaseAnswers();
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",