apex-auditor 0.3.3 → 0.3.6

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
@@ -16,10 +16,23 @@ From your web project root:
16
16
  pnpm dlx apex-auditor@latest
17
17
  ```
18
18
 
19
+ Notes:
20
+
21
+ - `init` can auto-detect your stack from `package.json` (Next.js, Nuxt, Remix/React Router, SvelteKit, SPA).
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.
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).
26
+
19
27
  Inside the interactive shell:
20
28
 
21
29
  - **measure**
22
30
  - **audit**
31
+ - **bundle** (scan build output sizes; writes `.apex-auditor/bundle-audit.json`)
32
+ - **health** (HTTP status/latency checks; writes `.apex-auditor/health.json`)
33
+ - **links** (broken links crawl; writes `.apex-auditor/links.json`)
34
+ - **headers** (security headers check; writes `.apex-auditor/headers.json`)
35
+ - **console** (console errors + runtime exceptions; writes `.apex-auditor/console.json`)
23
36
  - **open** (open the latest HTML report)
24
37
  - **init** (launch config wizard)
25
38
  - **config <path>** (switch config file)
@@ -65,6 +78,26 @@ Notes:
65
78
  - `measure-summary.json`
66
79
  - `measure/` (screenshots and artifacts)
67
80
 
81
+ ### `bundle` outputs
82
+
83
+ - `bundle-audit.json`
84
+
85
+ ### `health` outputs
86
+
87
+ - `health.json`
88
+
89
+ ### `links` outputs
90
+
91
+ - `links.json`
92
+
93
+ ### `headers` outputs
94
+
95
+ - `headers.json`
96
+
97
+ ### `console` outputs
98
+
99
+ - `console.json`
100
+
68
101
  ## Configuration
69
102
 
70
103
  ApexAuditor reads `apex.config.json` by default.
@@ -97,6 +130,13 @@ The docs in `docs/` reflect the current shell-based workflow:
97
130
  - `docs/configuration-and-routes.md`
98
131
  - `docs/cli-and-ci.md`
99
132
 
133
+ ## Known issues
134
+
135
+ - **Shell exits after init wizard**: in some environments, the process may exit after completing `init` in shell mode. Workaround: run `apex-auditor init` outside the shell, then re-run `apex-auditor shell`.
136
+ - **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.
137
+
138
+ The target for a truly stable release is after v0.3.7.
139
+
100
140
  ## License
101
141
 
102
142
  MIT
package/dist/bin.js CHANGED
@@ -4,6 +4,13 @@ import { runWizardCli } from "./wizard-cli.js";
4
4
  import { runQuickstartCli } from "./quickstart-cli.js";
5
5
  import { runShellCli } from "./shell-cli.js";
6
6
  import { runMeasureCli } from "./measure-cli.js";
7
+ import { runBundleCli } from "./bundle-cli.js";
8
+ import { runHealthCli } from "./health-cli.js";
9
+ import { runLinksCli } from "./links-cli.js";
10
+ import { runHeadersCli } from "./headers-cli.js";
11
+ import { runConsoleCli } from "./console-cli.js";
12
+ import { runCleanCli } from "./clean-cli.js";
13
+ import { runUninstallCli } from "./uninstall-cli.js";
7
14
  function parseBinArgs(argv) {
8
15
  const rawCommand = argv[2];
9
16
  if (rawCommand === undefined) {
@@ -18,6 +25,13 @@ function parseBinArgs(argv) {
18
25
  }
19
26
  if (rawCommand === "audit" ||
20
27
  rawCommand === "measure" ||
28
+ rawCommand === "bundle" ||
29
+ rawCommand === "health" ||
30
+ rawCommand === "links" ||
31
+ rawCommand === "headers" ||
32
+ rawCommand === "console" ||
33
+ rawCommand === "clean" ||
34
+ rawCommand === "uninstall" ||
21
35
  rawCommand === "wizard" ||
22
36
  rawCommand === "quickstart" ||
23
37
  rawCommand === "guide" ||
@@ -108,6 +122,13 @@ function printHelp(topic) {
108
122
  " guide Same as wizard, with inline tips for non-technical users",
109
123
  " audit Run Lighthouse audits using apex.config.json",
110
124
  " measure Fast batch metrics (CDP-based, non-Lighthouse)",
125
+ " bundle Bundle size audit (Next.js .next/ or dist/ build output)",
126
+ " health HTTP status + latency checks for configured routes",
127
+ " links Broken links audit (sitemap + HTML link extraction)",
128
+ " headers Security headers audit",
129
+ " console Console errors + runtime exceptions audit (headless Chrome)",
130
+ " clean Remove ApexAuditor artifacts (reports/cache and optionally config)",
131
+ " uninstall One-click uninstall (removes .apex-auditor/ and apex.config.json)",
111
132
  " help Show this help message",
112
133
  "",
113
134
  "Options (audit):",
@@ -127,7 +148,7 @@ function printHelp(topic) {
127
148
  " --yes, -y Auto-confirm large runs (bypass safety prompt)",
128
149
  " --changed-only Run only pages whose paths match files in git diff --name-only (working tree diff)",
129
150
  " --rerun-failing Re-run only combos that failed in the previous summary (runtime errors or perf<90)",
130
- " --accessibility-pass Run a fast axe-core accessibility sweep after audits (lightweight, CDP-based)",
151
+ " --accessibility-pass Opt-in: run a fast axe-core accessibility sweep after audits (lightweight, CDP-based)",
131
152
  " --webhook-url <url> Send a JSON webhook with regressions/budgets/accessibility (regressions-only summary)",
132
153
  " --show-parallel Print the resolved parallel workers before running.",
133
154
  " --incremental Reuse cached results for unchanged combos (requires --build-id). Opt-in; off by default.",
@@ -138,6 +159,64 @@ function printHelp(topic) {
138
159
  " --accurate Preset: devtools throttling + warm-up + stability-first (parallel=1 unless overridden)",
139
160
  " --open Open the HTML report after the run.",
140
161
  "",
162
+ "Options (measure):",
163
+ " --mobile-only Run measure only for 'mobile' devices defined in the config",
164
+ " --desktop-only Run measure only for 'desktop' devices defined in the config",
165
+ " --parallel <n> Override parallel workers (1-10).",
166
+ " --timeout-ms <ms> Per-navigation timeout in milliseconds (default 60000)",
167
+ " --screenshots Opt-in: save a screenshot per combo (slower; writes .apex-auditor/measure/*.png)",
168
+ " --json Print JSON summary to stdout",
169
+ "",
170
+ "Options (bundle):",
171
+ " --project-root <path> Project root to scan (default cwd)",
172
+ " --top <n> Show top N largest files (default 15)",
173
+ " --json Print JSON report to stdout",
174
+ "",
175
+ "Options (health):",
176
+ " --config <path> Config path (default apex.config.json)",
177
+ " --parallel <n> Parallel requests (default auto)",
178
+ " --timeout-ms <ms> Per-request timeout (default 20000)",
179
+ " --json Print JSON report to stdout",
180
+ "",
181
+ "Options (links):",
182
+ " --config <path> Config path (default apex.config.json)",
183
+ " --sitemap <url> Override sitemap URL (default <baseUrl>/sitemap.xml)",
184
+ " --parallel <n> Parallel requests (default auto)",
185
+ " --timeout-ms <ms> Per-request timeout (default 20000)",
186
+ " --max-urls <n> Limit total URLs checked (default 200)",
187
+ " --json Print JSON report to stdout",
188
+ "",
189
+ "Options (headers):",
190
+ " --config <path> Config path (default apex.config.json)",
191
+ " --parallel <n> Parallel requests (default auto)",
192
+ " --timeout-ms <ms> Per-request timeout (default 20000)",
193
+ " --json Print JSON report to stdout",
194
+ "",
195
+ "Options (console):",
196
+ " --config <path> Config path (default apex.config.json)",
197
+ " --parallel <n> Parallel workers (default auto)",
198
+ " --timeout-ms <ms> Per-navigation timeout (default 60000)",
199
+ " --max-events <n> Cap captured events per combo (default 50)",
200
+ " --json Print JSON report to stdout",
201
+ "",
202
+ "Options (clean):",
203
+ " --project-root <path> Project root (default cwd)",
204
+ " --config-path <path> Config file path relative to project root (default apex.config.json)",
205
+ " --reports Remove .apex-auditor/ (default)",
206
+ " --no-reports Keep .apex-auditor/",
207
+ " --remove-config Remove config file",
208
+ " --all Remove reports and config",
209
+ " --dry-run Print planned removals without deleting",
210
+ " --yes, -y Skip confirmation prompt",
211
+ " --json Print JSON report to stdout",
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
+ "",
141
220
  "Outputs:",
142
221
  " - Writes .apex-auditor/summary.json, summary.md, report.html",
143
222
  " - Prints a file:// link to the HTML report after completion",
@@ -159,6 +238,9 @@ function printHelp(topic) {
159
238
  " apex-auditor help budgets",
160
239
  ].join("\n"));
161
240
  }
241
+ function isInteractiveTty() {
242
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
243
+ }
162
244
  export async function runBin(argv) {
163
245
  const parsed = parseBinArgs(argv);
164
246
  if (parsed.command === "help") {
@@ -172,63 +254,67 @@ export async function runBin(argv) {
172
254
  }
173
255
  if (parsed.command === "quickstart") {
174
256
  await runQuickstartCli(parsed.argv);
257
+ if (isInteractiveTty()) {
258
+ await runShellCli(["node", "apex-auditor"]);
259
+ }
175
260
  return;
176
261
  }
177
- if (parsed.command === "audit") {
178
- try {
262
+ const runOnce = async () => {
263
+ if (parsed.command === "audit") {
179
264
  await runAuditCli(parsed.argv);
265
+ return;
180
266
  }
181
- catch (error) {
182
- const message = error instanceof Error ? error.message : String(error);
183
- if (message.includes("ENOENT")) {
184
- // eslint-disable-next-line no-console
185
- console.error("Config file not found. Run `apex-auditor init` to create a config or set one with `config <path>`.");
186
- return;
187
- }
188
- throw error;
267
+ if (parsed.command === "measure") {
268
+ await runMeasureCli(parsed.argv);
269
+ return;
189
270
  }
190
- if (process.stdin.isTTY && process.stdout.isTTY) {
191
- // eslint-disable-next-line no-console
192
- console.log("\nAudit completed. Press Ctrl+C to exit, or enter another command (type help for options).");
193
- await runShellCli(["node", "apex-auditor"]);
271
+ if (parsed.command === "bundle") {
272
+ await runBundleCli(parsed.argv);
273
+ return;
194
274
  }
195
- return;
196
- }
197
- if (parsed.command === "measure") {
198
- try {
199
- await runMeasureCli(parsed.argv);
275
+ if (parsed.command === "health") {
276
+ await runHealthCli(parsed.argv);
277
+ return;
200
278
  }
201
- catch (error) {
202
- const message = error instanceof Error ? error.message : String(error);
203
- if (message.includes("ENOENT")) {
204
- // eslint-disable-next-line no-console
205
- console.error("Config file not found. Run `apex-auditor init` to create a config or set one with `config <path>`.");
206
- return;
207
- }
208
- throw error;
279
+ if (parsed.command === "links") {
280
+ await runLinksCli(parsed.argv);
281
+ return;
209
282
  }
210
- if (process.stdin.isTTY && process.stdout.isTTY) {
211
- // eslint-disable-next-line no-console
212
- console.log("\nMeasure run completed. Press Ctrl+C to exit, or enter another command (type help for options).");
213
- await runShellCli(["node", "apex-auditor"]);
283
+ if (parsed.command === "headers") {
284
+ await runHeadersCli(parsed.argv);
285
+ return;
214
286
  }
215
- return;
287
+ if (parsed.command === "console") {
288
+ await runConsoleCli(parsed.argv);
289
+ return;
290
+ }
291
+ if (parsed.command === "clean") {
292
+ await runCleanCli(parsed.argv);
293
+ return;
294
+ }
295
+ if (parsed.command === "uninstall") {
296
+ await runUninstallCli(parsed.argv);
297
+ return;
298
+ }
299
+ if (parsed.command === "init" || parsed.command === "wizard" || parsed.command === "guide") {
300
+ await runWizardCli(parsed.argv);
301
+ return;
302
+ }
303
+ };
304
+ try {
305
+ await runOnce();
216
306
  }
217
- if (parsed.command === "init") {
218
- await runWizardCli(parsed.argv);
219
- if (process.stdin.isTTY && process.stdout.isTTY) {
307
+ catch (error) {
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ if (message.includes("ENOENT")) {
220
310
  // eslint-disable-next-line no-console
221
- console.log("\nConfig initialized. Press Ctrl+C to exit, or enter another command (type help for options).");
222
- await runShellCli(["node", "apex-auditor"]);
311
+ console.error("Config file not found. Run `apex-auditor init` to create a config or set one with `config <path>`.");
312
+ return;
223
313
  }
224
- return;
314
+ throw error;
225
315
  }
226
- if (parsed.command === "wizard" || parsed.command === "guide") {
227
- await runWizardCli(parsed.argv);
228
- if (process.stdin.isTTY && process.stdout.isTTY) {
229
- await runShellCli(["node", "apex-auditor"]);
230
- }
231
- return;
316
+ if (isInteractiveTty()) {
317
+ await runShellCli(["node", "apex-auditor"]);
232
318
  }
233
319
  }
234
320
  void runBin(process.argv).catch((error) => {
@@ -0,0 +1,158 @@
1
+ import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { renderPanel } from "./ui/render-panel.js";
4
+ import { renderTable } from "./ui/render-table.js";
5
+ import { UiTheme } from "./ui/ui-theme.js";
6
+ import { stopSpinner } from "./spinner.js";
7
+ const NO_COLOR = Boolean(process.env.NO_COLOR) || process.env.CI === "true";
8
+ const theme = new UiTheme({ noColor: NO_COLOR });
9
+ function formatBytes(bytes) {
10
+ const kb = bytes / 1024;
11
+ if (kb < 1024) {
12
+ return `${Math.round(kb)}KB`;
13
+ }
14
+ const mb = kb / 1024;
15
+ return `${mb.toFixed(1)}MB`;
16
+ }
17
+ async function pathExists(path) {
18
+ try {
19
+ await stat(path);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ async function walkFiles(params) {
27
+ const entries = [];
28
+ const visitDir = async (dir) => {
29
+ if (params.signal?.aborted) {
30
+ throw new Error("Aborted");
31
+ }
32
+ const children = await readdir(dir);
33
+ for (const name of children) {
34
+ if (params.signal?.aborted) {
35
+ throw new Error("Aborted");
36
+ }
37
+ const absolute = join(dir, name);
38
+ const s = await stat(absolute);
39
+ if (s.isDirectory()) {
40
+ await visitDir(absolute);
41
+ continue;
42
+ }
43
+ const matchedExt = params.extensions.find((ext) => name.endsWith(ext));
44
+ if (!matchedExt) {
45
+ continue;
46
+ }
47
+ const kind = matchedExt === ".css" ? "css" : "js";
48
+ const rel = relative(params.base, absolute).replace(/\\/g, "/");
49
+ entries.push({ kind, relativePath: rel, bytes: s.size });
50
+ }
51
+ };
52
+ await visitDir(params.root);
53
+ return entries;
54
+ }
55
+ function parseArgs(argv) {
56
+ let projectRoot = process.cwd();
57
+ let jsonOutput = false;
58
+ let top = 15;
59
+ for (let i = 2; i < argv.length; i += 1) {
60
+ const arg = argv[i];
61
+ if ((arg === "--project-root" || arg === "--root") && i + 1 < argv.length) {
62
+ projectRoot = argv[i + 1] ?? projectRoot;
63
+ i += 1;
64
+ }
65
+ else if (arg === "--json") {
66
+ jsonOutput = true;
67
+ }
68
+ else if (arg === "--top" && i + 1 < argv.length) {
69
+ const value = parseInt(argv[i + 1] ?? "", 10);
70
+ if (!Number.isFinite(value) || value <= 0 || value > 100) {
71
+ throw new Error(`Invalid --top value: ${argv[i + 1]}. Expected 1-100.`);
72
+ }
73
+ top = value;
74
+ i += 1;
75
+ }
76
+ }
77
+ return { projectRoot, jsonOutput, top };
78
+ }
79
+ function buildTopFilesTable(entries, top) {
80
+ const rows = [...entries]
81
+ .sort((a, b) => b.bytes - a.bytes)
82
+ .slice(0, top)
83
+ .map((f) => [f.kind, formatBytes(f.bytes), f.relativePath]);
84
+ if (rows.length === 0) {
85
+ return "";
86
+ }
87
+ return renderTable({ headers: ["Type", "Size", "File"], rows });
88
+ }
89
+ export async function runBundleCli(argv, options) {
90
+ stopSpinner();
91
+ const args = parseArgs(argv);
92
+ const projectRoot = resolve(args.projectRoot);
93
+ const nextDir = resolve(projectRoot, ".next");
94
+ const distDir = resolve(projectRoot, "dist");
95
+ const hasNext = await pathExists(nextDir);
96
+ const hasDist = await pathExists(distDir);
97
+ const scanTargets = [
98
+ hasNext ? resolve(nextDir, "static") : "",
99
+ hasDist ? distDir : "",
100
+ ].filter((p) => p.length > 0);
101
+ const allEntries = [];
102
+ for (const target of scanTargets) {
103
+ if (options?.signal?.aborted) {
104
+ throw new Error("Aborted");
105
+ }
106
+ const entries = await walkFiles({
107
+ root: target,
108
+ base: projectRoot,
109
+ extensions: [".js", ".css"],
110
+ signal: options?.signal,
111
+ });
112
+ allEntries.push(...entries);
113
+ }
114
+ const jsBytes = allEntries.filter((e) => e.kind === "js").reduce((sum, e) => sum + e.bytes, 0);
115
+ const cssBytes = allEntries.filter((e) => e.kind === "css").reduce((sum, e) => sum + e.bytes, 0);
116
+ const topFiles = [...allEntries].sort((a, b) => b.bytes - a.bytes).slice(0, args.top);
117
+ const report = {
118
+ meta: {
119
+ projectRoot,
120
+ scannedAt: new Date().toISOString(),
121
+ detected: { nextDir: hasNext, distDir: hasDist },
122
+ },
123
+ totals: {
124
+ jsBytes,
125
+ cssBytes,
126
+ fileCount: allEntries.length,
127
+ },
128
+ topFiles,
129
+ files: allEntries,
130
+ };
131
+ const outputDir = resolve(projectRoot, ".apex-auditor");
132
+ const outputPath = resolve(outputDir, "bundle-audit.json");
133
+ await mkdir(outputDir, { recursive: true });
134
+ await writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
135
+ if (args.jsonOutput) {
136
+ // eslint-disable-next-line no-console
137
+ console.log(JSON.stringify(report, null, 2));
138
+ return;
139
+ }
140
+ const detectedText = [hasNext ? ".next" : "", hasDist ? "dist" : ""].filter((v) => v.length > 0).join(", ") || "(none)";
141
+ const lines = [
142
+ `Project: ${projectRoot}`,
143
+ `Detected: ${detectedText}`,
144
+ `Files: ${report.totals.fileCount}`,
145
+ `JS: ${formatBytes(report.totals.jsBytes)}`,
146
+ `CSS: ${formatBytes(report.totals.cssBytes)}`,
147
+ `Output: ${relative(projectRoot, outputPath).replace(/\\/g, "/")}`,
148
+ ];
149
+ // eslint-disable-next-line no-console
150
+ console.log(renderPanel({ title: theme.bold("Bundle"), lines }));
151
+ const table = buildTopFilesTable(allEntries, args.top);
152
+ if (table.length > 0) {
153
+ // eslint-disable-next-line no-console
154
+ console.log(`\n${theme.bold(`Largest files (top ${args.top})`)}`);
155
+ // eslint-disable-next-line no-console
156
+ console.log(table);
157
+ }
158
+ }
@@ -0,0 +1,159 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import readline from "node:readline";
4
+ function parseArgs(argv) {
5
+ let projectRoot = process.cwd();
6
+ let configPath = "apex.config.json";
7
+ let removeReports = true;
8
+ let removeConfig = false;
9
+ let dryRun = false;
10
+ let yes = false;
11
+ let jsonOutput = false;
12
+ for (let i = 2; i < argv.length; i += 1) {
13
+ const arg = argv[i] ?? "";
14
+ if (arg === "--project-root" && i + 1 < argv.length) {
15
+ projectRoot = argv[i + 1] ?? projectRoot;
16
+ i += 1;
17
+ continue;
18
+ }
19
+ if ((arg === "--config-path" || arg === "--config") && i + 1 < argv.length) {
20
+ configPath = argv[i + 1] ?? configPath;
21
+ i += 1;
22
+ continue;
23
+ }
24
+ if (arg === "--reports") {
25
+ removeReports = true;
26
+ continue;
27
+ }
28
+ if (arg === "--no-reports") {
29
+ removeReports = false;
30
+ continue;
31
+ }
32
+ if (arg === "--remove-config") {
33
+ removeConfig = true;
34
+ continue;
35
+ }
36
+ if (arg === "--all") {
37
+ removeReports = true;
38
+ removeConfig = true;
39
+ continue;
40
+ }
41
+ if (arg === "--dry-run") {
42
+ dryRun = true;
43
+ continue;
44
+ }
45
+ if (arg === "--yes" || arg === "-y") {
46
+ yes = true;
47
+ continue;
48
+ }
49
+ if (arg === "--json") {
50
+ jsonOutput = true;
51
+ continue;
52
+ }
53
+ }
54
+ return {
55
+ projectRoot: resolve(projectRoot),
56
+ configPath: resolve(projectRoot, configPath),
57
+ removeReports,
58
+ removeConfig,
59
+ dryRun,
60
+ yes,
61
+ jsonOutput,
62
+ };
63
+ }
64
+ async function confirmPrompt(question) {
65
+ if (!process.stdin.isTTY) {
66
+ return false;
67
+ }
68
+ process.stdin.resume();
69
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
70
+ try {
71
+ const answer = await new Promise((resolvePromise) => {
72
+ rl.question(question, (value) => resolvePromise(value));
73
+ });
74
+ const text = answer.trim().toLowerCase();
75
+ return text === "y" || text === "yes";
76
+ }
77
+ finally {
78
+ rl.close();
79
+ }
80
+ }
81
+ function buildPlan(args) {
82
+ const actions = [];
83
+ if (args.removeReports) {
84
+ actions.push({ kind: "rm", path: resolve(args.projectRoot, ".apex-auditor"), existsByAssumption: true });
85
+ }
86
+ if (args.removeConfig) {
87
+ actions.push({ kind: "rm", path: args.configPath, existsByAssumption: true });
88
+ }
89
+ return actions;
90
+ }
91
+ async function executePlan(plan, dryRun) {
92
+ if (dryRun) {
93
+ return [];
94
+ }
95
+ const executed = [];
96
+ for (const action of plan) {
97
+ if (action.kind === "rm") {
98
+ await rm(action.path, { recursive: true, force: true });
99
+ executed.push(action);
100
+ }
101
+ }
102
+ return executed;
103
+ }
104
+ export async function runCleanCli(argv) {
105
+ const startedAtMs = Date.now();
106
+ const args = parseArgs(argv);
107
+ const planned = buildPlan(args);
108
+ if (planned.length === 0) {
109
+ const empty = {
110
+ meta: {
111
+ projectRoot: args.projectRoot,
112
+ configPath: args.configPath,
113
+ dryRun: args.dryRun,
114
+ startedAt: new Date(startedAtMs).toISOString(),
115
+ completedAt: new Date(startedAtMs).toISOString(),
116
+ elapsedMs: 0,
117
+ },
118
+ planned: [],
119
+ executed: [],
120
+ };
121
+ if (args.jsonOutput) {
122
+ console.log(JSON.stringify(empty, null, 2));
123
+ return;
124
+ }
125
+ console.log("Nothing to clean.");
126
+ return;
127
+ }
128
+ if (!args.yes && process.stdin.isTTY) {
129
+ const targets = planned.map((p) => p.path).join("\n");
130
+ const ok = await confirmPrompt(`This will remove:\n${targets}\nContinue? (y/N) `);
131
+ if (!ok) {
132
+ console.log("Cancelled.");
133
+ return;
134
+ }
135
+ }
136
+ const executed = await executePlan(planned, args.dryRun);
137
+ const completedAtMs = Date.now();
138
+ const report = {
139
+ meta: {
140
+ projectRoot: args.projectRoot,
141
+ configPath: args.configPath,
142
+ dryRun: args.dryRun,
143
+ startedAt: new Date(startedAtMs).toISOString(),
144
+ completedAt: new Date(completedAtMs).toISOString(),
145
+ elapsedMs: completedAtMs - startedAtMs,
146
+ },
147
+ planned,
148
+ executed,
149
+ };
150
+ if (args.jsonOutput) {
151
+ console.log(JSON.stringify(report, null, 2));
152
+ return;
153
+ }
154
+ if (args.dryRun) {
155
+ console.log(`Planned removals: ${planned.length} (dry-run).`);
156
+ return;
157
+ }
158
+ console.log(`Removed: ${executed.length}/${planned.length}.`);
159
+ }