apex-auditor 0.3.0 → 0.3.3
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 +49 -100
- package/dist/accessibility-types.js +1 -0
- package/dist/accessibility.js +152 -0
- package/dist/axe-script.js +26 -0
- package/dist/bin.js +183 -9
- package/dist/cdp-client.js +264 -0
- package/dist/cli.js +1549 -82
- package/dist/config.js +11 -0
- package/dist/lighthouse-runner.js +524 -54
- package/dist/lighthouse-worker.js +248 -0
- package/dist/measure-cli.js +139 -0
- package/dist/measure-runner.js +447 -0
- package/dist/measure-types.js +1 -0
- package/dist/shell-cli.js +566 -0
- package/dist/spinner.js +37 -0
- package/dist/ui/render-panel.js +46 -0
- package/dist/ui/render-table.js +61 -0
- package/dist/ui/ui-theme.js +47 -0
- package/dist/url.js +6 -0
- package/dist/webhooks.js +29 -0
- package/dist/wizard-cli.js +14 -22
- package/package.json +4 -2
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { runAuditCli } from "./cli.js";
|
|
6
|
+
import { runMeasureCli } from "./measure-cli.js";
|
|
7
|
+
import { runWizardCli } from "./wizard-cli.js";
|
|
8
|
+
import { pathExists } from "./fs-utils.js";
|
|
9
|
+
import { renderPanel } from "./ui/render-panel.js";
|
|
10
|
+
import { startSpinner, stopSpinner } from "./spinner.js";
|
|
11
|
+
import { UiTheme } from "./ui/ui-theme.js";
|
|
12
|
+
const SESSION_DIR_NAME = ".apex-auditor";
|
|
13
|
+
const SESSION_FILE_NAME = "session.json";
|
|
14
|
+
const DEFAULT_CONFIG_PATH = "apex.config.json";
|
|
15
|
+
const DEFAULT_PROMPT = "> ";
|
|
16
|
+
const NO_COLOR = Boolean(process.env.NO_COLOR) || process.env.CI === "true";
|
|
17
|
+
const theme = new UiTheme({ noColor: NO_COLOR });
|
|
18
|
+
async function readJsonFile(absolutePath) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(absolutePath, "utf8");
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (!parsed || typeof parsed !== "object") {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function writeJsonFile(absolutePath, value) {
|
|
32
|
+
await writeFile(absolutePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
33
|
+
}
|
|
34
|
+
function getSessionPaths(projectRoot) {
|
|
35
|
+
const dir = resolve(projectRoot, SESSION_DIR_NAME);
|
|
36
|
+
const file = join(dir, SESSION_FILE_NAME);
|
|
37
|
+
return { dir, file };
|
|
38
|
+
}
|
|
39
|
+
async function loadSession(projectRoot) {
|
|
40
|
+
const { dir, file } = getSessionPaths(projectRoot);
|
|
41
|
+
if (!(await pathExists(dir))) {
|
|
42
|
+
return {
|
|
43
|
+
configPath: DEFAULT_CONFIG_PATH,
|
|
44
|
+
preset: "default",
|
|
45
|
+
incremental: false,
|
|
46
|
+
buildIdStrategy: "auto",
|
|
47
|
+
buildIdManual: undefined,
|
|
48
|
+
lastReportPath: undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const existing = await readJsonFile(file);
|
|
52
|
+
if (existing === undefined) {
|
|
53
|
+
return {
|
|
54
|
+
configPath: DEFAULT_CONFIG_PATH,
|
|
55
|
+
preset: "default",
|
|
56
|
+
incremental: false,
|
|
57
|
+
buildIdStrategy: "auto",
|
|
58
|
+
buildIdManual: undefined,
|
|
59
|
+
lastReportPath: undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return existing;
|
|
63
|
+
}
|
|
64
|
+
async function saveSession(projectRoot, session) {
|
|
65
|
+
const { dir, file } = getSessionPaths(projectRoot);
|
|
66
|
+
await mkdir(dir, { recursive: true });
|
|
67
|
+
await writeJsonFile(file, session);
|
|
68
|
+
}
|
|
69
|
+
function parseShellCommand(line) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (trimmed.length === 0) {
|
|
72
|
+
return { id: "", args: [] };
|
|
73
|
+
}
|
|
74
|
+
const parts = trimmed.split(/\s+/g);
|
|
75
|
+
const id = parts[0] ?? "";
|
|
76
|
+
const args = parts.slice(1);
|
|
77
|
+
return { id, args };
|
|
78
|
+
}
|
|
79
|
+
function buildPrompt(session) {
|
|
80
|
+
const incText = session.incremental ? "on" : "off";
|
|
81
|
+
const presetText = session.preset;
|
|
82
|
+
const configText = session.configPath;
|
|
83
|
+
const header = [
|
|
84
|
+
theme.cyan("ApexAuditor"),
|
|
85
|
+
theme.dim("config:"),
|
|
86
|
+
configText,
|
|
87
|
+
theme.dim("| preset:"),
|
|
88
|
+
presetText,
|
|
89
|
+
theme.dim("| incremental:"),
|
|
90
|
+
incText,
|
|
91
|
+
].join(" ");
|
|
92
|
+
return `${header}\n${theme.dim(DEFAULT_PROMPT)}`;
|
|
93
|
+
}
|
|
94
|
+
function openInBrowser(filePath) {
|
|
95
|
+
const platform = process.platform;
|
|
96
|
+
const command = platform === "win32" ? `start "" "${filePath}"` : platform === "darwin" ? `open "${filePath}"` : `xdg-open "${filePath}"`;
|
|
97
|
+
exec(command, (error) => {
|
|
98
|
+
if (error) {
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.error(`Could not open report: ${error.message}`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function snapshotPreviousSummary(projectRoot) {
|
|
105
|
+
const currentPath = resolve(projectRoot, SESSION_DIR_NAME, "summary.json");
|
|
106
|
+
const prevPath = resolve(projectRoot, SESSION_DIR_NAME, "summary.prev.json");
|
|
107
|
+
if (!(await pathExists(currentPath))) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const raw = await readFile(currentPath, "utf8");
|
|
112
|
+
await mkdir(resolve(projectRoot, SESSION_DIR_NAME), { recursive: true });
|
|
113
|
+
await writeFile(prevPath, raw, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function computeAvgScores(results) {
|
|
120
|
+
const sums = results.reduce((acc, r) => {
|
|
121
|
+
return {
|
|
122
|
+
performance: acc.performance + (r.scores.performance ?? 0),
|
|
123
|
+
accessibility: acc.accessibility + (r.scores.accessibility ?? 0),
|
|
124
|
+
bestPractices: acc.bestPractices + (r.scores.bestPractices ?? 0),
|
|
125
|
+
seo: acc.seo + (r.scores.seo ?? 0),
|
|
126
|
+
count: acc.count + 1,
|
|
127
|
+
};
|
|
128
|
+
}, { performance: 0, accessibility: 0, bestPractices: 0, seo: 0, count: 0 });
|
|
129
|
+
const count = Math.max(1, sums.count);
|
|
130
|
+
return {
|
|
131
|
+
performance: Math.round(sums.performance / count),
|
|
132
|
+
accessibility: Math.round(sums.accessibility / count),
|
|
133
|
+
bestPractices: Math.round(sums.bestPractices / count),
|
|
134
|
+
seo: Math.round(sums.seo / count),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function formatChanges(previous, current) {
|
|
138
|
+
const prevAvg = computeAvgScores(previous.results);
|
|
139
|
+
const currAvg = computeAvgScores(current.results);
|
|
140
|
+
const avgDelta = {
|
|
141
|
+
performance: currAvg.performance - prevAvg.performance,
|
|
142
|
+
accessibility: currAvg.accessibility - prevAvg.accessibility,
|
|
143
|
+
bestPractices: currAvg.bestPractices - prevAvg.bestPractices,
|
|
144
|
+
seo: currAvg.seo - prevAvg.seo,
|
|
145
|
+
};
|
|
146
|
+
const prevMap = new Map(previous.results.map((r) => [`${r.label}:::${r.path}:::${r.device}`, r]));
|
|
147
|
+
const currMap = new Map(current.results.map((r) => [`${r.label}:::${r.path}:::${r.device}`, r]));
|
|
148
|
+
const allKeys = new Set([...prevMap.keys(), ...currMap.keys()]);
|
|
149
|
+
const deltas = [];
|
|
150
|
+
let added = 0;
|
|
151
|
+
let removed = 0;
|
|
152
|
+
for (const key of allKeys) {
|
|
153
|
+
const prev = prevMap.get(key);
|
|
154
|
+
const curr = currMap.get(key);
|
|
155
|
+
if (!prev && curr) {
|
|
156
|
+
added += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (prev && !curr) {
|
|
160
|
+
removed += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!prev || !curr) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const deltaP = (curr.scores.performance ?? 0) - (prev.scores.performance ?? 0);
|
|
167
|
+
deltas.push({ key, label: curr.label, path: curr.path, device: curr.device, deltaP });
|
|
168
|
+
}
|
|
169
|
+
deltas.sort((a, b) => a.deltaP - b.deltaP);
|
|
170
|
+
const regressions = deltas.slice(0, 5);
|
|
171
|
+
const improvements = [...deltas].reverse().slice(0, 5);
|
|
172
|
+
const lines = [];
|
|
173
|
+
lines.push(`Avg deltas: P ${avgDelta.performance} | A ${avgDelta.accessibility} | BP ${avgDelta.bestPractices} | SEO ${avgDelta.seo}`);
|
|
174
|
+
lines.push(`Combos: +${added} added, -${removed} removed`);
|
|
175
|
+
lines.push("Top regressions (Performance):");
|
|
176
|
+
for (const r of regressions) {
|
|
177
|
+
lines.push(`- ${r.label} ${r.path} [${r.device}] ΔP:${r.deltaP}`);
|
|
178
|
+
}
|
|
179
|
+
lines.push("Top improvements (Performance):");
|
|
180
|
+
for (const r of improvements) {
|
|
181
|
+
lines.push(`- ${r.label} ${r.path} [${r.device}] ΔP:${r.deltaP}`);
|
|
182
|
+
}
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
async function runDiff(projectRoot) {
|
|
186
|
+
const prevPath = resolve(projectRoot, SESSION_DIR_NAME, "summary.prev.json");
|
|
187
|
+
const currPath = resolve(projectRoot, SESSION_DIR_NAME, "summary.json");
|
|
188
|
+
const prev = await readJsonFile(prevPath);
|
|
189
|
+
const curr = await readJsonFile(currPath);
|
|
190
|
+
if (!prev || !curr) {
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.log("No diff available. Run 'audit' at least twice in this shell session.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.log(formatChanges(prev, curr));
|
|
197
|
+
}
|
|
198
|
+
function buildAuditArgvFromSession(session) {
|
|
199
|
+
const args = ["node", "apex-auditor"];
|
|
200
|
+
if (session.configPath.length > 0) {
|
|
201
|
+
args.push("--config", session.configPath);
|
|
202
|
+
}
|
|
203
|
+
if (session.preset === "overview") {
|
|
204
|
+
args.push("--overview");
|
|
205
|
+
}
|
|
206
|
+
if (session.preset === "fast") {
|
|
207
|
+
args.push("--fast");
|
|
208
|
+
}
|
|
209
|
+
if (session.preset === "quick") {
|
|
210
|
+
args.push("--quick");
|
|
211
|
+
}
|
|
212
|
+
if (session.preset === "accurate") {
|
|
213
|
+
args.push("--accurate");
|
|
214
|
+
}
|
|
215
|
+
if (session.incremental) {
|
|
216
|
+
args.push("--incremental");
|
|
217
|
+
}
|
|
218
|
+
if (session.buildIdStrategy === "manual" && session.buildIdManual) {
|
|
219
|
+
args.push("--build-id", session.buildIdManual);
|
|
220
|
+
}
|
|
221
|
+
return args;
|
|
222
|
+
}
|
|
223
|
+
function buildAuditArgv(session, passthroughArgs) {
|
|
224
|
+
const baseArgv = buildAuditArgvFromSession(session);
|
|
225
|
+
if (passthroughArgs.length === 0) {
|
|
226
|
+
return baseArgv;
|
|
227
|
+
}
|
|
228
|
+
return [...baseArgv, ...passthroughArgs];
|
|
229
|
+
}
|
|
230
|
+
function resolvePresetFromArgs(args) {
|
|
231
|
+
const preset = args[0];
|
|
232
|
+
if (preset === "default" || preset === "overview" || preset === "quick" || preset === "accurate" || preset === "fast") {
|
|
233
|
+
return preset;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
function resolveBoolFromArgs(args) {
|
|
238
|
+
const raw = args[0];
|
|
239
|
+
if (raw === "on") {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (raw === "off") {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
function resolveBuildIdStrategy(args) {
|
|
248
|
+
const raw = args[0];
|
|
249
|
+
if (raw === "auto") {
|
|
250
|
+
return { strategy: "auto", manual: undefined };
|
|
251
|
+
}
|
|
252
|
+
if (raw === "manual") {
|
|
253
|
+
const id = args[1];
|
|
254
|
+
if (!id || id.trim().length === 0) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
return { strategy: "manual", manual: id.trim() };
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
function printHelp() {
|
|
262
|
+
const lines = [];
|
|
263
|
+
lines.push(`${theme.cyan("measure")} Run fast metrics (CDP-based) for the current config`);
|
|
264
|
+
lines.push("");
|
|
265
|
+
lines.push(theme.bold("Commands"));
|
|
266
|
+
lines.push(`${theme.cyan("audit")} Run audits using the current session settings`);
|
|
267
|
+
lines.push(`${theme.cyan("measure")} Run fast metrics (CDP-based) for the current config`);
|
|
268
|
+
lines.push(`${theme.cyan("open")} Open the last HTML report (or .apex-auditor/report.html)`);
|
|
269
|
+
lines.push(`${theme.cyan("diff")} Compare last run vs previous run (from this shell session)`);
|
|
270
|
+
lines.push(`${theme.cyan("preset <id>")} Set preset: default|overview|quick|accurate|fast`);
|
|
271
|
+
lines.push(`${theme.cyan("incremental on|off")} Toggle incremental caching`);
|
|
272
|
+
lines.push(`${theme.cyan("build-id auto")} Use auto buildId detection`);
|
|
273
|
+
lines.push(`${theme.cyan("build-id manual <id>")} Use a fixed buildId`);
|
|
274
|
+
lines.push(`${theme.cyan("config <path>")} Set config path used by audit`);
|
|
275
|
+
lines.push("");
|
|
276
|
+
lines.push(theme.dim("Note: runs-per-combo is always 1. For baselines/comparison, rerun the same command."));
|
|
277
|
+
lines.push("");
|
|
278
|
+
lines.push(`${theme.cyan("help")} Show this help`);
|
|
279
|
+
lines.push(`${theme.cyan("exit")} Exit the shell`);
|
|
280
|
+
// eslint-disable-next-line no-console
|
|
281
|
+
console.log(renderPanel({ title: theme.bold("Help"), lines }));
|
|
282
|
+
}
|
|
283
|
+
async function readCliVersion(projectRoot) {
|
|
284
|
+
try {
|
|
285
|
+
const raw = await readFile(resolve(projectRoot, "package.json"), "utf8");
|
|
286
|
+
const parsed = JSON.parse(raw);
|
|
287
|
+
if (!parsed || typeof parsed !== "object") {
|
|
288
|
+
return "unknown";
|
|
289
|
+
}
|
|
290
|
+
const record = parsed;
|
|
291
|
+
return typeof record.version === "string" ? record.version : "unknown";
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return "unknown";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function printHomeScreen(params) {
|
|
298
|
+
const { version, session } = params;
|
|
299
|
+
const padCmd = (cmd) => cmd.padEnd(14, " ");
|
|
300
|
+
const lines = [];
|
|
301
|
+
lines.push(theme.dim("Performance + metrics assistant (measure-first, Lighthouse optional)"));
|
|
302
|
+
lines.push("");
|
|
303
|
+
lines.push(theme.bold("Audit commands"));
|
|
304
|
+
lines.push(`${theme.cyan(padCmd("measure"))}Fast batch metrics (LCP/CLS/INP + screenshot + console errors)`);
|
|
305
|
+
lines.push(`${theme.cyan(padCmd("audit"))}Deep Lighthouse audit (slower)`);
|
|
306
|
+
lines.push("");
|
|
307
|
+
lines.push(theme.bold("Common commands"));
|
|
308
|
+
lines.push(`${theme.cyan(padCmd("init"))}Launch config wizard to create/edit apex.config.json`);
|
|
309
|
+
lines.push(`${theme.cyan(padCmd("config <path>"))}Change config file (current: ${session.configPath})`);
|
|
310
|
+
lines.push(`${theme.cyan(padCmd("help"))}Show all commands`);
|
|
311
|
+
lines.push("");
|
|
312
|
+
lines.push(theme.bold("Tips"));
|
|
313
|
+
lines.push(theme.dim("- Press Tab for auto-completion"));
|
|
314
|
+
lines.push(theme.dim("- Press Ctrl+C or type exit to quit"));
|
|
315
|
+
// eslint-disable-next-line no-console
|
|
316
|
+
console.log(renderPanel({ title: theme.magenta(theme.bold(`ApexAuditor v${version}`)), lines }));
|
|
317
|
+
}
|
|
318
|
+
function createCompleter() {
|
|
319
|
+
const commands = [
|
|
320
|
+
"audit",
|
|
321
|
+
"measure",
|
|
322
|
+
"open",
|
|
323
|
+
"diff",
|
|
324
|
+
"preset",
|
|
325
|
+
"incremental",
|
|
326
|
+
"build-id",
|
|
327
|
+
"config",
|
|
328
|
+
"help",
|
|
329
|
+
"exit",
|
|
330
|
+
"quit",
|
|
331
|
+
];
|
|
332
|
+
const presets = ["default", "overview", "quick", "accurate", "fast"];
|
|
333
|
+
const onOff = ["on", "off"];
|
|
334
|
+
const buildIdModes = ["auto", "manual"];
|
|
335
|
+
const measureFlags = ["--desktop-only", "--mobile-only", "--parallel", "--timeout-ms", "--json"];
|
|
336
|
+
const filterStartsWith = (candidates, fragment) => {
|
|
337
|
+
const hits = candidates.filter((c) => c.startsWith(fragment));
|
|
338
|
+
return hits.length > 0 ? hits : candidates;
|
|
339
|
+
};
|
|
340
|
+
const completeFirstWord = (trimmed, rawLine) => {
|
|
341
|
+
const hits = commands.filter((c) => c.startsWith(trimmed));
|
|
342
|
+
return [hits.length > 0 ? hits : commands, rawLine];
|
|
343
|
+
};
|
|
344
|
+
const completeSecondWord = (command, fragment, rawLine) => {
|
|
345
|
+
if (command === "preset") {
|
|
346
|
+
return [filterStartsWith(presets, fragment), rawLine];
|
|
347
|
+
}
|
|
348
|
+
if (command === "incremental") {
|
|
349
|
+
return [filterStartsWith(onOff, fragment), rawLine];
|
|
350
|
+
}
|
|
351
|
+
if (command === "build-id") {
|
|
352
|
+
return [filterStartsWith(buildIdModes, fragment), rawLine];
|
|
353
|
+
}
|
|
354
|
+
if (command === "measure") {
|
|
355
|
+
return [filterStartsWith(measureFlags, fragment), rawLine];
|
|
356
|
+
}
|
|
357
|
+
return [[], rawLine];
|
|
358
|
+
};
|
|
359
|
+
return (line) => {
|
|
360
|
+
const rawLine = line;
|
|
361
|
+
const trimmedStart = rawLine.trimStart();
|
|
362
|
+
const parts = trimmedStart.split(/\s+/g);
|
|
363
|
+
const hasTrailingSpace = rawLine.endsWith(" ");
|
|
364
|
+
const command = parts[0] ?? "";
|
|
365
|
+
if (parts.length <= 1) {
|
|
366
|
+
const fragment = command;
|
|
367
|
+
return completeFirstWord(fragment, rawLine);
|
|
368
|
+
}
|
|
369
|
+
const secondFragment = hasTrailingSpace ? "" : (parts[1] ?? "");
|
|
370
|
+
return completeSecondWord(command, secondFragment, rawLine);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function runAudit(projectRoot, session, passthroughArgs) {
|
|
374
|
+
await snapshotPreviousSummary(projectRoot);
|
|
375
|
+
const argv = buildAuditArgv(session, passthroughArgs);
|
|
376
|
+
// eslint-disable-next-line no-console
|
|
377
|
+
console.log("Starting audit. Tip: large runs may prompt for confirmation; pass --yes to skip the prompt.");
|
|
378
|
+
await runAuditCli(argv);
|
|
379
|
+
}
|
|
380
|
+
async function runMeasure(session, passthroughArgs) {
|
|
381
|
+
const argv = ["node", "apex-auditor", "--config", session.configPath, ...passthroughArgs];
|
|
382
|
+
startSpinner("Running measure (fast metrics)");
|
|
383
|
+
try {
|
|
384
|
+
// eslint-disable-next-line no-console
|
|
385
|
+
console.log("Starting measure (fast metrics). Tip: use --desktop-only/--mobile-only and --parallel to tune speed.");
|
|
386
|
+
await runMeasureCli(argv);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
stopSpinner();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function runWithEscAbort(task) {
|
|
393
|
+
const controller = new AbortController();
|
|
394
|
+
const input = process.stdin;
|
|
395
|
+
const handleKeypress = (buffer) => {
|
|
396
|
+
if (buffer.length === 1 && buffer[0] === 0x1b) {
|
|
397
|
+
controller.abort();
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
const previousRaw = input.isTTY ? input.isRaw : undefined;
|
|
401
|
+
if (input.isTTY) {
|
|
402
|
+
input.setRawMode(true);
|
|
403
|
+
}
|
|
404
|
+
input.on("data", handleKeypress);
|
|
405
|
+
try {
|
|
406
|
+
const result = await task(controller.signal);
|
|
407
|
+
if (controller.signal.aborted) {
|
|
408
|
+
return "aborted";
|
|
409
|
+
}
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
input.off("data", handleKeypress);
|
|
414
|
+
if (input.isTTY && previousRaw !== undefined) {
|
|
415
|
+
input.setRawMode(previousRaw);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function runAuditFromShell(projectRoot, session, args) {
|
|
420
|
+
const escResult = await runWithEscAbort(async (signal) => {
|
|
421
|
+
await runAuditCli(buildAuditArgv(session, args), { signal });
|
|
422
|
+
});
|
|
423
|
+
if (escResult === "aborted") {
|
|
424
|
+
// eslint-disable-next-line no-console
|
|
425
|
+
console.log("Audit cancelled via Esc. Back to shell.");
|
|
426
|
+
process.exitCode = 0;
|
|
427
|
+
return session;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
if (process.exitCode === 130) {
|
|
431
|
+
process.exitCode = 0;
|
|
432
|
+
// eslint-disable-next-line no-console
|
|
433
|
+
console.log("Audit cancelled. Back to shell.");
|
|
434
|
+
return session;
|
|
435
|
+
}
|
|
436
|
+
const reportPath = resolve(projectRoot, SESSION_DIR_NAME, "report.html");
|
|
437
|
+
// eslint-disable-next-line no-console
|
|
438
|
+
console.log(`Tip: type ${theme.cyan("open")} to view the latest HTML report.`);
|
|
439
|
+
const updated = { ...session, lastReportPath: reportPath };
|
|
440
|
+
await saveSession(projectRoot, updated);
|
|
441
|
+
return updated;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
if (message.includes("ENOENT") && message.includes(session.configPath)) {
|
|
446
|
+
// eslint-disable-next-line no-console
|
|
447
|
+
console.log(`Config not found at ${session.configPath}. Run 'init' to create a new config for this project, or use 'config <path>' to point to an existing one.`);
|
|
448
|
+
return session;
|
|
449
|
+
}
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function handleShellCommand(projectRoot, session, command) {
|
|
454
|
+
if (command.id === "" || command.id === "status") {
|
|
455
|
+
// eslint-disable-next-line no-console
|
|
456
|
+
console.log(buildPrompt(session).replace(`\n${DEFAULT_PROMPT}`, ""));
|
|
457
|
+
return { session, shouldExit: false };
|
|
458
|
+
}
|
|
459
|
+
if (command.id === "help") {
|
|
460
|
+
printHelp();
|
|
461
|
+
return { session, shouldExit: false };
|
|
462
|
+
}
|
|
463
|
+
if (command.id === "exit" || command.id === "quit") {
|
|
464
|
+
return { session, shouldExit: true };
|
|
465
|
+
}
|
|
466
|
+
if (command.id === "audit") {
|
|
467
|
+
const nextSession = await runAuditFromShell(projectRoot, session, command.args);
|
|
468
|
+
return { session: nextSession, shouldExit: false };
|
|
469
|
+
}
|
|
470
|
+
if (command.id === "measure") {
|
|
471
|
+
await runMeasure(session, command.args);
|
|
472
|
+
return { session, shouldExit: false };
|
|
473
|
+
}
|
|
474
|
+
if (command.id === "init") {
|
|
475
|
+
// eslint-disable-next-line no-console
|
|
476
|
+
console.log("Starting config wizard...");
|
|
477
|
+
await runWizardCli(["node", "apex-auditor"]);
|
|
478
|
+
return { session, shouldExit: false };
|
|
479
|
+
}
|
|
480
|
+
if (command.id === "open") {
|
|
481
|
+
const path = session.lastReportPath ?? resolve(projectRoot, SESSION_DIR_NAME, "report.html");
|
|
482
|
+
openInBrowser(path);
|
|
483
|
+
return { session, shouldExit: false };
|
|
484
|
+
}
|
|
485
|
+
if (command.id === "diff") {
|
|
486
|
+
await runDiff(projectRoot);
|
|
487
|
+
return { session, shouldExit: false };
|
|
488
|
+
}
|
|
489
|
+
if (command.id === "preset") {
|
|
490
|
+
const preset = resolvePresetFromArgs(command.args);
|
|
491
|
+
if (!preset) {
|
|
492
|
+
// eslint-disable-next-line no-console
|
|
493
|
+
console.log("Usage: preset default|quick|accurate|fast");
|
|
494
|
+
return { session, shouldExit: false };
|
|
495
|
+
}
|
|
496
|
+
const updated = { ...session, preset };
|
|
497
|
+
await saveSession(projectRoot, updated);
|
|
498
|
+
return { session: updated, shouldExit: false };
|
|
499
|
+
}
|
|
500
|
+
if (command.id === "incremental") {
|
|
501
|
+
const value = resolveBoolFromArgs(command.args);
|
|
502
|
+
if (value === undefined) {
|
|
503
|
+
// eslint-disable-next-line no-console
|
|
504
|
+
console.log("Usage: incremental on|off");
|
|
505
|
+
return { session, shouldExit: false };
|
|
506
|
+
}
|
|
507
|
+
const updated = { ...session, incremental: value };
|
|
508
|
+
await saveSession(projectRoot, updated);
|
|
509
|
+
return { session: updated, shouldExit: false };
|
|
510
|
+
}
|
|
511
|
+
if (command.id === "build-id") {
|
|
512
|
+
const resolved = resolveBuildIdStrategy(command.args);
|
|
513
|
+
if (!resolved) {
|
|
514
|
+
// eslint-disable-next-line no-console
|
|
515
|
+
console.log("Usage: build-id auto | build-id manual <id>");
|
|
516
|
+
return { session, shouldExit: false };
|
|
517
|
+
}
|
|
518
|
+
const updated = { ...session, buildIdStrategy: resolved.strategy, buildIdManual: resolved.manual };
|
|
519
|
+
await saveSession(projectRoot, updated);
|
|
520
|
+
return { session: updated, shouldExit: false };
|
|
521
|
+
}
|
|
522
|
+
if (command.id === "config") {
|
|
523
|
+
const configPath = command.args[0];
|
|
524
|
+
if (!configPath || configPath.trim().length === 0) {
|
|
525
|
+
// eslint-disable-next-line no-console
|
|
526
|
+
console.log("Usage: config <path-to-config.json>");
|
|
527
|
+
return { session, shouldExit: false };
|
|
528
|
+
}
|
|
529
|
+
const updated = { ...session, configPath: configPath.trim() };
|
|
530
|
+
await saveSession(projectRoot, updated);
|
|
531
|
+
return { session: updated, shouldExit: false };
|
|
532
|
+
}
|
|
533
|
+
// eslint-disable-next-line no-console
|
|
534
|
+
console.log(`Unknown command: ${command.id}. Type 'help' to see commands.`);
|
|
535
|
+
return { session, shouldExit: false };
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Starts ApexAuditor in interactive shell mode.
|
|
539
|
+
*/
|
|
540
|
+
export async function runShellCli(argv) {
|
|
541
|
+
void argv;
|
|
542
|
+
const projectRoot = process.cwd();
|
|
543
|
+
let session = await loadSession(projectRoot);
|
|
544
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer: createCompleter() });
|
|
545
|
+
rl.on("SIGINT", () => {
|
|
546
|
+
rl.close();
|
|
547
|
+
});
|
|
548
|
+
const version = await readCliVersion(projectRoot);
|
|
549
|
+
printHomeScreen({ version, session });
|
|
550
|
+
rl.setPrompt(buildPrompt(session));
|
|
551
|
+
rl.prompt();
|
|
552
|
+
rl.on("line", async (line) => {
|
|
553
|
+
const command = parseShellCommand(line);
|
|
554
|
+
const result = await handleShellCommand(projectRoot, session, command);
|
|
555
|
+
session = result.session;
|
|
556
|
+
if (result.shouldExit) {
|
|
557
|
+
rl.close();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
rl.setPrompt(buildPrompt(session));
|
|
561
|
+
rl.prompt();
|
|
562
|
+
});
|
|
563
|
+
await new Promise((resolvePromise) => {
|
|
564
|
+
rl.on("close", () => resolvePromise());
|
|
565
|
+
});
|
|
566
|
+
}
|
package/dist/spinner.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
3
|
+
const ANSI_BLUE = "\u001B[34m";
|
|
4
|
+
const ANSI_RESET = "\u001B[0m";
|
|
5
|
+
let spinnerInterval;
|
|
6
|
+
let spinnerIndex = 0;
|
|
7
|
+
let spinnerMessage = "";
|
|
8
|
+
export function startSpinner(message) {
|
|
9
|
+
if (!process.stdout.isTTY) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
stopSpinner();
|
|
13
|
+
spinnerMessage = message;
|
|
14
|
+
spinnerIndex = 0;
|
|
15
|
+
process.stdout.write("\u001B[?25l"); // hide cursor
|
|
16
|
+
spinnerInterval = setInterval(() => {
|
|
17
|
+
process.stdout.write(`\r${ANSI_BLUE}${SPINNER_FRAMES[spinnerIndex]} ${spinnerMessage}${ANSI_RESET}`);
|
|
18
|
+
spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
19
|
+
}, SPINNER_INTERVAL_MS);
|
|
20
|
+
}
|
|
21
|
+
export function updateSpinnerMessage(message) {
|
|
22
|
+
if (!process.stdout.isTTY || spinnerInterval === undefined) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
spinnerMessage = message;
|
|
26
|
+
}
|
|
27
|
+
export function stopSpinner() {
|
|
28
|
+
if (spinnerInterval === undefined) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
clearInterval(spinnerInterval);
|
|
32
|
+
spinnerInterval = undefined;
|
|
33
|
+
if (process.stdout.isTTY) {
|
|
34
|
+
process.stdout.write("\r\u001B[K"); // clear line
|
|
35
|
+
process.stdout.write("\u001B[?25h"); // show cursor
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const BOX = {
|
|
2
|
+
topLeft: "┌",
|
|
3
|
+
topRight: "┐",
|
|
4
|
+
bottomLeft: "└",
|
|
5
|
+
bottomRight: "┘",
|
|
6
|
+
horizontal: "─",
|
|
7
|
+
vertical: "│",
|
|
8
|
+
};
|
|
9
|
+
function repeatChar(char, count) {
|
|
10
|
+
return new Array(Math.max(0, count)).fill(char).join("");
|
|
11
|
+
}
|
|
12
|
+
function lineLength(value) {
|
|
13
|
+
return value.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
14
|
+
}
|
|
15
|
+
function padRight(value, total) {
|
|
16
|
+
const rawLen = lineLength(value);
|
|
17
|
+
if (rawLen >= total) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return `${value}${repeatChar(" ", total - rawLen)}`;
|
|
21
|
+
}
|
|
22
|
+
function renderHeader(params) {
|
|
23
|
+
const titleLine = params.subtitle ? `${params.title} — ${params.subtitle}` : params.title;
|
|
24
|
+
const padded = padRight(titleLine, params.contentWidth);
|
|
25
|
+
return [`${BOX.vertical} ${padded} ${BOX.vertical}`];
|
|
26
|
+
}
|
|
27
|
+
function renderBody(lines, contentWidth) {
|
|
28
|
+
return lines.map((line) => {
|
|
29
|
+
const padded = padRight(line, contentWidth);
|
|
30
|
+
return `${BOX.vertical} ${padded} ${BOX.vertical}`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function computeContentWidth(params) {
|
|
34
|
+
const headerLine = params.subtitle ? `${params.title} — ${params.subtitle}` : params.title;
|
|
35
|
+
const lengths = [lineLength(headerLine), ...params.lines.map((l) => lineLength(l))];
|
|
36
|
+
return Math.max(10, ...lengths);
|
|
37
|
+
}
|
|
38
|
+
export function renderPanel(params) {
|
|
39
|
+
const contentWidth = computeContentWidth(params);
|
|
40
|
+
const top = `${BOX.topLeft}${repeatChar(BOX.horizontal, contentWidth + 2)}${BOX.topRight}`;
|
|
41
|
+
const bottom = `${BOX.bottomLeft}${repeatChar(BOX.horizontal, contentWidth + 2)}${BOX.bottomRight}`;
|
|
42
|
+
const header = renderHeader({ title: params.title, subtitle: params.subtitle, contentWidth });
|
|
43
|
+
const separator = `${BOX.vertical} ${repeatChar(BOX.horizontal, contentWidth)} ${BOX.vertical}`;
|
|
44
|
+
const body = renderBody(params.lines, contentWidth);
|
|
45
|
+
return [top, ...header, separator, ...body, bottom].join("\n");
|
|
46
|
+
}
|