apex-auditor 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { runAuditCli } from "./cli.js";
3
3
  import { runWizardCli } from "./wizard-cli.js";
4
+ import { runQuickstartCli } from "./quickstart-cli.js";
4
5
  function parseBinArgs(argv) {
5
6
  const rawCommand = argv[2];
6
7
  if (rawCommand === undefined || rawCommand === "help" || rawCommand === "--help" || rawCommand === "-h") {
7
8
  return { command: "help", argv };
8
9
  }
9
- if (rawCommand === "audit" || rawCommand === "wizard") {
10
+ if (rawCommand === "audit" || rawCommand === "wizard" || rawCommand === "quickstart") {
10
11
  const commandArgv = ["node", "apex-auditor", ...argv.slice(3)];
11
12
  return { command: rawCommand, argv: commandArgv };
12
13
  }
@@ -17,13 +18,21 @@ function printHelp() {
17
18
  "ApexAuditor CLI",
18
19
  "",
19
20
  "Usage:",
21
+ " apex-auditor quickstart --base-url <url> [--project-root <path>]",
20
22
  " apex-auditor wizard [--config <path>]",
21
- " apex-auditor audit [--config <path>]",
23
+ " apex-auditor audit [--config <path>] [--ci] [--no-color|--color] [--log-level <level>]",
22
24
  "",
23
25
  "Commands:",
26
+ " quickstart Detect routes and run a one-off audit with sensible defaults",
24
27
  " wizard Run interactive config wizard",
25
28
  " audit Run Lighthouse audits using apex.config.json",
26
29
  " help Show this help message",
30
+ "",
31
+ "Options (audit):",
32
+ " --ci Enable CI mode with budgets and non-zero exit code on failure",
33
+ " --no-color Disable ANSI colours in console output (default in CI mode)",
34
+ " --color Force ANSI colours in console output",
35
+ " --log-level <lvl> Override Lighthouse log level: silent|error|info|verbose",
27
36
  ].join("\n"));
28
37
  }
29
38
  export async function runBin(argv) {
@@ -32,6 +41,10 @@ export async function runBin(argv) {
32
41
  printHelp();
33
42
  return;
34
43
  }
44
+ if (parsed.command === "quickstart") {
45
+ await runQuickstartCli(parsed.argv);
46
+ return;
47
+ }
35
48
  if (parsed.command === "audit") {
36
49
  await runAuditCli(parsed.argv);
37
50
  return;
package/dist/cli.js CHANGED
@@ -8,15 +8,37 @@ const ANSI_YELLOW = "\u001B[33m";
8
8
  const ANSI_GREEN = "\u001B[32m";
9
9
  function parseArgs(argv) {
10
10
  let configPath;
11
+ let ci = false;
12
+ let colorMode = "auto";
13
+ let logLevelOverride;
11
14
  for (let i = 2; i < argv.length; i += 1) {
12
15
  const arg = argv[i];
13
16
  if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
14
17
  configPath = argv[i + 1];
15
18
  i += 1;
16
19
  }
20
+ else if (arg === "--ci") {
21
+ ci = true;
22
+ }
23
+ else if (arg === "--no-color") {
24
+ colorMode = "off";
25
+ }
26
+ else if (arg === "--color") {
27
+ colorMode = "on";
28
+ }
29
+ else if (arg === "--log-level" && i + 1 < argv.length) {
30
+ const value = argv[i + 1];
31
+ if (value === "silent" || value === "error" || value === "info" || value === "verbose") {
32
+ logLevelOverride = value;
33
+ }
34
+ else {
35
+ throw new Error(`Invalid --log-level value: ${value}`);
36
+ }
37
+ i += 1;
38
+ }
17
39
  }
18
40
  const finalConfigPath = configPath ?? "apex.config.json";
19
- return { configPath: finalConfigPath };
41
+ return { configPath: finalConfigPath, ci, colorMode, logLevelOverride };
20
42
  }
21
43
  /**
22
44
  * Runs the ApexAuditor audit CLI.
@@ -26,17 +48,24 @@ function parseArgs(argv) {
26
48
  export async function runAuditCli(argv) {
27
49
  const args = parseArgs(argv);
28
50
  const { configPath, config } = await loadConfig({ configPath: args.configPath });
29
- const summary = await runAuditsForConfig({ config, configPath });
51
+ const effectiveLogLevel = args.logLevelOverride ?? config.logLevel;
52
+ const effectiveConfig = {
53
+ ...config,
54
+ logLevel: effectiveLogLevel,
55
+ };
56
+ const summary = await runAuditsForConfig({ config: effectiveConfig, configPath });
30
57
  const outputDir = resolve(".apex-auditor");
31
58
  await mkdir(outputDir, { recursive: true });
32
59
  await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
33
60
  const markdown = buildMarkdown(summary.results);
34
61
  await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
35
62
  // Also echo a compact, colourised table to stdout for quick viewing.
36
- const consoleTable = buildConsoleTable(summary.results);
63
+ const useColor = shouldUseColor(args.ci, args.colorMode);
64
+ const consoleTable = buildConsoleTable(summary.results, useColor);
37
65
  // eslint-disable-next-line no-console
38
66
  console.log(consoleTable);
39
67
  printRedIssues(summary.results);
68
+ printCiSummary(args, summary.results, effectiveConfig.budgets);
40
69
  }
41
70
  function buildMarkdown(results) {
42
71
  const header = [
@@ -46,12 +75,12 @@ function buildMarkdown(results) {
46
75
  const lines = results.map((result) => buildRow(result));
47
76
  return `${header}\n${lines.join("\n")}`;
48
77
  }
49
- function buildConsoleTable(results) {
78
+ function buildConsoleTable(results, useColor) {
50
79
  const header = [
51
80
  "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Error | Top issues |",
52
81
  "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-------|-----------|",
53
82
  ].join("\n");
54
- const lines = results.map((result) => buildConsoleRow(result));
83
+ const lines = results.map((result) => buildConsoleRow(result, useColor));
55
84
  return `${header}\n${lines.join("\n")}`;
56
85
  }
57
86
  function buildRow(result) {
@@ -65,7 +94,7 @@ function buildRow(result) {
65
94
  const error = result.runtimeErrorCode ?? (result.runtimeErrorMessage !== undefined ? result.runtimeErrorMessage : "");
66
95
  return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${error} | ${issues} |`;
67
96
  }
68
- function buildConsoleRow(result) {
97
+ function buildConsoleRow(result, useColor) {
69
98
  const scores = result.scores;
70
99
  const metrics = result.metrics;
71
100
  const lcpSeconds = metrics.lcpMs !== undefined ? (metrics.lcpMs / 1000).toFixed(1) : "-";
@@ -74,10 +103,10 @@ function buildConsoleRow(result) {
74
103
  const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
75
104
  const issues = formatTopIssues(result.opportunities);
76
105
  const error = result.runtimeErrorCode ?? (result.runtimeErrorMessage !== undefined ? result.runtimeErrorMessage : "");
77
- const performanceText = colourScore(scores.performance);
78
- const accessibilityText = colourScore(scores.accessibility);
79
- const bestPracticesText = colourScore(scores.bestPractices);
80
- const seoText = colourScore(scores.seo);
106
+ const performanceText = colourScore(scores.performance, useColor);
107
+ const accessibilityText = colourScore(scores.accessibility, useColor);
108
+ const bestPracticesText = colourScore(scores.bestPractices, useColor);
109
+ const seoText = colourScore(scores.seo, useColor);
81
110
  return `| ${result.label} | ${result.path} | ${result.device} | ${performanceText} | ${accessibilityText} | ${bestPracticesText} | ${seoText} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${error} | ${issues} |`;
82
111
  }
83
112
  function formatTopIssues(opportunities) {
@@ -93,12 +122,15 @@ function formatTopIssues(opportunities) {
93
122
  });
94
123
  return items.join("; ");
95
124
  }
96
- function colourScore(score) {
125
+ function colourScore(score, useColor) {
97
126
  if (score === undefined) {
98
127
  return "-";
99
128
  }
100
129
  const value = score;
101
130
  const text = value.toString();
131
+ if (!useColor) {
132
+ return text;
133
+ }
102
134
  let colour;
103
135
  if (value < 50) {
104
136
  colour = ANSI_RED;
@@ -147,3 +179,98 @@ function printRedIssues(results) {
147
179
  console.log(`- ${result.label} ${result.path} [${result.device}] – ${badParts.join(", ")} – ${issues}`);
148
180
  }
149
181
  }
182
+ function shouldUseColor(ci, colorMode) {
183
+ if (colorMode === "on") {
184
+ return true;
185
+ }
186
+ if (colorMode === "off") {
187
+ return false;
188
+ }
189
+ if (ci) {
190
+ return false;
191
+ }
192
+ return typeof process !== "undefined" && Boolean(process.stdout && process.stdout.isTTY);
193
+ }
194
+ function printCiSummary(args, results, budgets) {
195
+ if (!args.ci) {
196
+ return;
197
+ }
198
+ if (!budgets) {
199
+ // eslint-disable-next-line no-console
200
+ console.log("\nCI mode: no budgets configured. Skipping threshold checks.");
201
+ return;
202
+ }
203
+ const violations = collectBudgetViolations(results, budgets);
204
+ if (violations.length === 0) {
205
+ // eslint-disable-next-line no-console
206
+ console.log("\nCI budgets PASSED.");
207
+ return;
208
+ }
209
+ // eslint-disable-next-line no-console
210
+ console.log(`\nCI budgets FAILED (${violations.length} violations):`);
211
+ for (const violation of violations) {
212
+ // eslint-disable-next-line no-console
213
+ console.log(`- ${violation.pageLabel} ${violation.path} [${violation.device}] – ${violation.kind} ${violation.id}: ${violation.value} vs limit ${violation.limit}`);
214
+ }
215
+ process.exitCode = 1;
216
+ }
217
+ function collectBudgetViolations(results, budgets) {
218
+ const violations = [];
219
+ for (const result of results) {
220
+ if (budgets.categories) {
221
+ collectCategoryViolations(result, budgets.categories, violations);
222
+ }
223
+ if (budgets.metrics) {
224
+ collectMetricViolations(result, budgets.metrics, violations);
225
+ }
226
+ }
227
+ return violations;
228
+ }
229
+ function collectCategoryViolations(result, categories, allViolations) {
230
+ const scores = result.scores;
231
+ addCategoryViolation("performance", scores.performance, categories.performance, result, allViolations);
232
+ addCategoryViolation("accessibility", scores.accessibility, categories.accessibility, result, allViolations);
233
+ addCategoryViolation("bestPractices", scores.bestPractices, categories.bestPractices, result, allViolations);
234
+ addCategoryViolation("seo", scores.seo, categories.seo, result, allViolations);
235
+ }
236
+ function addCategoryViolation(id, actual, limit, result, allViolations) {
237
+ if (limit === undefined || actual === undefined) {
238
+ return;
239
+ }
240
+ if (actual >= limit) {
241
+ return;
242
+ }
243
+ allViolations.push({
244
+ pageLabel: result.label,
245
+ path: result.path,
246
+ device: result.device,
247
+ kind: "category",
248
+ id,
249
+ value: actual,
250
+ limit,
251
+ });
252
+ }
253
+ function collectMetricViolations(result, metricsBudgets, allViolations) {
254
+ const metrics = result.metrics;
255
+ addMetricViolation("lcpMs", metrics.lcpMs, metricsBudgets.lcpMs, result, allViolations);
256
+ addMetricViolation("fcpMs", metrics.fcpMs, metricsBudgets.fcpMs, result, allViolations);
257
+ addMetricViolation("tbtMs", metrics.tbtMs, metricsBudgets.tbtMs, result, allViolations);
258
+ addMetricViolation("cls", metrics.cls, metricsBudgets.cls, result, allViolations);
259
+ }
260
+ function addMetricViolation(id, actual, limit, result, allViolations) {
261
+ if (limit === undefined || actual === undefined) {
262
+ return;
263
+ }
264
+ if (actual <= limit) {
265
+ return;
266
+ }
267
+ allViolations.push({
268
+ pageLabel: result.label,
269
+ path: result.path,
270
+ device: result.device,
271
+ kind: "metric",
272
+ id,
273
+ value: actual,
274
+ limit,
275
+ });
276
+ }
package/dist/config.js CHANGED
@@ -31,6 +31,7 @@ function normaliseConfig(input, absolutePath) {
31
31
  const logLevel = rawLogLevel === "silent" || rawLogLevel === "error" || rawLogLevel === "info" || rawLogLevel === "verbose"
32
32
  ? rawLogLevel
33
33
  : undefined;
34
+ const budgets = normaliseBudgets(maybeConfig.budgets, absolutePath);
34
35
  return {
35
36
  baseUrl,
36
37
  query,
@@ -38,6 +39,7 @@ function normaliseConfig(input, absolutePath) {
38
39
  runs,
39
40
  logLevel,
40
41
  pages,
42
+ budgets,
41
43
  };
42
44
  }
43
45
  function normalisePage(page, index, absolutePath) {
@@ -66,3 +68,86 @@ function normalisePage(page, index, absolutePath) {
66
68
  devices,
67
69
  };
68
70
  }
71
+ function normaliseBudgets(input, absolutePath) {
72
+ if (input === undefined) {
73
+ return undefined;
74
+ }
75
+ if (!input || typeof input !== "object") {
76
+ throw new Error(`Invalid budgets in ${absolutePath}: expected object`);
77
+ }
78
+ const maybeBudgets = input;
79
+ const categories = normaliseCategoryBudgets(maybeBudgets.categories, absolutePath);
80
+ const metrics = normaliseMetricBudgets(maybeBudgets.metrics, absolutePath);
81
+ if (!categories && !metrics) {
82
+ return undefined;
83
+ }
84
+ return {
85
+ categories,
86
+ metrics,
87
+ };
88
+ }
89
+ function normaliseCategoryBudgets(input, absolutePath) {
90
+ if (input === undefined) {
91
+ return undefined;
92
+ }
93
+ if (!input || typeof input !== "object") {
94
+ throw new Error(`Invalid budgets.categories in ${absolutePath}: expected object`);
95
+ }
96
+ const maybeCategories = input;
97
+ const performance = normaliseScoreBudget(maybeCategories.performance, "performance", absolutePath);
98
+ const accessibility = normaliseScoreBudget(maybeCategories.accessibility, "accessibility", absolutePath);
99
+ const bestPractices = normaliseScoreBudget(maybeCategories.bestPractices, "bestPractices", absolutePath);
100
+ const seo = normaliseScoreBudget(maybeCategories.seo, "seo", absolutePath);
101
+ if (performance === undefined &&
102
+ accessibility === undefined &&
103
+ bestPractices === undefined &&
104
+ seo === undefined) {
105
+ return undefined;
106
+ }
107
+ return {
108
+ performance,
109
+ accessibility,
110
+ bestPractices,
111
+ seo,
112
+ };
113
+ }
114
+ function normaliseMetricBudgets(input, absolutePath) {
115
+ if (input === undefined) {
116
+ return undefined;
117
+ }
118
+ if (!input || typeof input !== "object") {
119
+ throw new Error(`Invalid budgets.metrics in ${absolutePath}: expected object`);
120
+ }
121
+ const maybeMetrics = input;
122
+ const lcpMs = normaliseMetricBudget(maybeMetrics.lcpMs, "lcpMs", absolutePath);
123
+ const fcpMs = normaliseMetricBudget(maybeMetrics.fcpMs, "fcpMs", absolutePath);
124
+ const tbtMs = normaliseMetricBudget(maybeMetrics.tbtMs, "tbtMs", absolutePath);
125
+ const cls = normaliseMetricBudget(maybeMetrics.cls, "cls", absolutePath);
126
+ if (lcpMs === undefined && fcpMs === undefined && tbtMs === undefined && cls === undefined) {
127
+ return undefined;
128
+ }
129
+ return {
130
+ lcpMs,
131
+ fcpMs,
132
+ tbtMs,
133
+ cls,
134
+ };
135
+ }
136
+ function normaliseScoreBudget(value, key, absolutePath) {
137
+ if (value === undefined) {
138
+ return undefined;
139
+ }
140
+ if (typeof value !== "number" || value < 0 || value > 100) {
141
+ throw new Error(`Invalid budgets.categories.${key} in ${absolutePath}: expected number between 0 and 100`);
142
+ }
143
+ return value;
144
+ }
145
+ function normaliseMetricBudget(value, key, absolutePath) {
146
+ if (value === undefined) {
147
+ return undefined;
148
+ }
149
+ if (typeof value !== "number" || value < 0) {
150
+ throw new Error(`Invalid budgets.metrics.${key} in ${absolutePath}: expected non-negative number`);
151
+ }
152
+ return value;
153
+ }
@@ -0,0 +1,118 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import prompts from "prompts";
4
+ import { detectRoutes } from "./route-detectors.js";
5
+ import { pathExists } from "./fs-utils.js";
6
+ import { runAuditCli } from "./cli.js";
7
+ const DEFAULT_BASE_URL = "http://localhost:3000";
8
+ const DEFAULT_RUNS = 1;
9
+ const DEFAULT_DEVICES = ["mobile", "desktop"];
10
+ const DEFAULT_ROUTE_LIMIT = 5;
11
+ const QUICKSTART_CONFIG_NAME = "quickstart.config.json";
12
+ const PROMPT_OPTIONS = { onCancel: handleCancel };
13
+ const baseUrlQuestion = {
14
+ type: "text",
15
+ name: "baseUrl",
16
+ message: "Base URL of the running app",
17
+ initial: DEFAULT_BASE_URL,
18
+ validate: (value) => (value.startsWith("http") ? true : "Enter a full http(s) URL."),
19
+ };
20
+ function handleCancel() {
21
+ // eslint-disable-next-line no-console
22
+ console.log("Quickstart cancelled. No audits run.");
23
+ process.exit(1);
24
+ return true;
25
+ }
26
+ async function ask(question) {
27
+ const answers = await prompts(question, PROMPT_OPTIONS);
28
+ return answers;
29
+ }
30
+ function parseQuickstartArgs(argv) {
31
+ let baseUrl;
32
+ let projectRoot = ".";
33
+ for (let index = 2; index < argv.length; index += 1) {
34
+ const arg = argv[index];
35
+ if ((arg === "--base-url" || arg === "-b") && index + 1 < argv.length) {
36
+ baseUrl = argv[index + 1];
37
+ index += 1;
38
+ }
39
+ else if ((arg === "--project-root" || arg === "-p") && index + 1 < argv.length) {
40
+ projectRoot = argv[index + 1];
41
+ index += 1;
42
+ }
43
+ }
44
+ return { baseUrl, projectRoot };
45
+ }
46
+ async function resolveBaseUrl(cliBaseUrl) {
47
+ if (cliBaseUrl && cliBaseUrl.length > 0) {
48
+ return cliBaseUrl;
49
+ }
50
+ // eslint-disable-next-line no-console
51
+ console.error("Quickstart requires --base-url <url>. No base URL was provided.");
52
+ process.exit(1);
53
+ return DEFAULT_BASE_URL;
54
+ }
55
+ async function resolveProjectRoot(rawProjectRoot) {
56
+ const absolutePath = resolve(rawProjectRoot);
57
+ if (await pathExists(absolutePath)) {
58
+ return absolutePath;
59
+ }
60
+ // eslint-disable-next-line no-console
61
+ console.log(`Project root ${absolutePath} does not exist. Falling back to current directory.`);
62
+ return process.cwd();
63
+ }
64
+ async function detectQuickstartPages(projectRoot) {
65
+ const routes = await detectRoutes({ projectRoot, limit: DEFAULT_ROUTE_LIMIT });
66
+ if (routes.length === 0) {
67
+ return buildFallbackPages();
68
+ }
69
+ return routes.map((route) => convertRouteToPage(route));
70
+ }
71
+ function buildFallbackPages() {
72
+ return [
73
+ {
74
+ path: "/",
75
+ label: "home",
76
+ devices: DEFAULT_DEVICES,
77
+ },
78
+ ];
79
+ }
80
+ function convertRouteToPage(route) {
81
+ return {
82
+ path: route.path,
83
+ label: route.label,
84
+ devices: DEFAULT_DEVICES,
85
+ };
86
+ }
87
+ async function writeQuickstartConfig(config) {
88
+ const outputDir = resolve(".apex-auditor");
89
+ await mkdir(outputDir, { recursive: true });
90
+ const configPath = join(outputDir, QUICKSTART_CONFIG_NAME);
91
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
92
+ return configPath;
93
+ }
94
+ async function buildQuickstartConfig(args) {
95
+ const baseUrl = await resolveBaseUrl(args.baseUrl);
96
+ const projectRoot = await resolveProjectRoot(args.projectRoot);
97
+ const pages = await detectQuickstartPages(projectRoot);
98
+ const config = {
99
+ baseUrl,
100
+ pages,
101
+ runs: DEFAULT_RUNS,
102
+ };
103
+ return writeQuickstartConfig(config);
104
+ }
105
+ /**
106
+ * Run the ApexAuditor quickstart flow.
107
+ *
108
+ * This command discovers common routes from the current project, asks for a base URL
109
+ * if needed, writes a temporary config file, and then delegates to the audit CLI.
110
+ *
111
+ * @param argv - The process arguments array.
112
+ */
113
+ export async function runQuickstartCli(argv) {
114
+ const args = parseQuickstartArgs(argv);
115
+ const configPath = await buildQuickstartConfig(args);
116
+ const auditArgv = ["node", "apex-auditor", "--config", configPath];
117
+ await runAuditCli(auditArgv);
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",