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.
@@ -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
+ }
@@ -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
+ }