vibesane 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vibesane Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # vibesane
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Preflight sanity check for vibe-coded web apps, typically under 3 seconds. Zero config, zero runtime dependencies, instant copy-paste AI fix prompts.
4
+
5
+ ## Quickstart
6
+
7
+ Start your local dev server (`npm run dev`), then run in another terminal:
8
+
9
+ ```bash
10
+ npx vibesane
11
+ ```
12
+
13
+ Check a specific port:
14
+
15
+ ```bash
16
+ npx vibesane --port 8080
17
+ ```
18
+
19
+ **Requirement:** Node.js `>= 22.13.0`.
20
+
21
+ ## Pipe Directly to Clipboard (`pbcopy` / `clip`)
22
+
23
+ All scan output goes to `stdout` (`stderr` receives only CLI usage errors, exit code `2`, and unexpected internal errors), so you can pipe the report and AI fix prompt directly to your clipboard:
24
+
25
+ macOS:
26
+
27
+ ```bash
28
+ npx vibesane | pbcopy
29
+ ```
30
+
31
+ Windows (`cmd.exe` or PowerShell):
32
+
33
+ ```powershell
34
+ npx vibesane | clip
35
+ ```
36
+
37
+ > **Windows UTF-8 Note:** `vibesane` emits pure ASCII headers, separators, and code frames so `| clip` works cleanly even under the default Windows codepage (`CP850`). If your own application code contains accented characters (e.g., Portuguese or Spanish) that you want to preserve when copying on Windows:
38
+ > - **PowerShell:** `[Console]::OutputEncoding = [Text.Encoding]::UTF8; npx vibesane | Set-Clipboard`
39
+ > - **`cmd.exe`:** `chcp 65001 >nul && npx vibesane | clip`
40
+
41
+ ## Real Output Example
42
+
43
+ ```text
44
+ vibesane: sanity check failed on http://localhost:5173 (2 error(s))
45
+ error [http_error]: HTTP 500 on script /src/components/Pay.tsx:2:98 - [PARSE_ERROR] Expected `,` or `}` but found `Identifier`
46
+ +-[ src/components/Pay.tsx:2:98 ]
47
+ |
48
+ 2 | const broken = { stripeKey: "sk_live_****abcd", msg: "Olá, senha inválida" b: 2 };
49
+ | + +
50
+ | +---------------------------------------------------------------------------------- Opened here
51
+ | |
52
+ | +-- `,` or `}` expected
53
+ ---+
54
+ error [secret_leak]: Secret leak on /src/components/Pay.tsx: Stripe secret key (sk_live_****abcd)
55
+
56
+ AI FIX PROMPT (copy & paste into Cursor / Claude Code / Gemini CLI):
57
+ Target: http://localhost:5173
58
+ 1. [http_error] HTTP 500 on script /src/components/Pay.tsx:2:98 - [PARSE_ERROR] Expected `,` or `}` but found `Identifier`
59
+ +-[ src/components/Pay.tsx:2:98 ]
60
+ |
61
+ 2 | const broken = { stripeKey: "sk_live_****abcd", msg: "Olá, senha inválida" b: 2 };
62
+ | + +
63
+ | +---------------------------------------------------------------------------------- Opened here
64
+ | |
65
+ | +-- `,` or `}` expected
66
+ ---+
67
+ 2. [secret_leak] Secret leak on /src/components/Pay.tsx: Stripe secret key (sk_live_****abcd)
68
+ Instruction (secrets): Move this key to a server-only environment variable (never NEXT_PUBLIC_ / VITE_) and rotate it if it was ever committed or deployed.
69
+ Instruction (code): Fix only the error pointed out above; do not refactor unrelated code.
70
+ ```
71
+
72
+ ## What It Checks
73
+
74
+ - **Dual-Stack Port Auto-Detection:** Probes `127.0.0.1` and `::1` across `3000, 3001, 5173, 5174, 8080, 8000, 4321` and confirms an active HTTP server.
75
+ - **HTTP Status & Route Crawling:** Checks `/` plus up to 10 internal routes (`<a href>`) and up to 5 application scripts/modules (`<script src>` and first-level `/src/` ESM imports) for `4xx`/`5xx` responses.
76
+ - **Masked Error Overlays on `200 OK`:** Detects Next.js and Vite runtime/compile error overlays hidden inside `200 OK` HTML responses, including Next 16 RSC/Flight error payloads and Vite/Oxc code frames.
77
+ - **Client-Side Secret Leaks:** Scans HTML and JS bundles for exposed secrets (`sk_live_*`, `sk_test_*`, `rk_live_*`, `rk_test_*`, `AIzaSy*`, `ghp_*`, `sk-proj-*`, `sk-ant-*`, `*_SECRET`, `*_PRIVATE_KEY`, `DATABASE_URL` with credentials, and hardcoded `password`/`senha` assignments), resolving the original source file inside Turbopack and Webpack chunks while ignoring public keys (`pk_live_*`, `pk_test_*`, `NEXT_PUBLIC_*`, `VITE_*`).
78
+ - **Slow Routes:** Flags routes taking `> 1500ms` to return headers as `warning [slow_route]`.
79
+
80
+ ## Next.js Cold Start (On-Demand Compilation)
81
+
82
+ Right after starting `next dev`, Next.js compiles routes on demand on the very first request:
83
+
84
+ - If the root route `/` takes **more than 2 seconds** to compile, the port detector exits with an HTTP timeout (exit code `1`: `port 3000 accepted the connection but took >2s to respond (still compiling?)`).
85
+ - If sub-routes take too long to compile on first hit, they are reported as `warning [slow_route]` or `info: N route(s) not checked (time budget reached)`.
86
+ - **Fix:** Simply **run `npx vibesane` again** — once the dev server has compiled and cached the route, the scan finishes in milliseconds.
87
+
88
+ ## Limitations (What It Does Not Do)
89
+
90
+ - **No Headless Browser (Not Playwright/Cypress):** Runs fast HTTP/HTML/JS inspection via `fetch`. It does not execute client-side JavaScript, click buttons, submit forms, or detect crashes that only trigger after DOM user interaction.
91
+ - **HTML-Based Route Discovery:** Discovers sub-routes from `<a href="...">` links rendered in the HTML of `/`. In pure client-side SPAs where `<div id="root"></div>` is empty and routes exist only inside a client-side router, `vibesane` inspects `/` and up to 5 application JS modules (`/src/*`), but does not navigate client-only routes absent from initial HTML.
92
+ - **No Authentication / Session State:** Protected routes redirecting to external login providers are skipped; same-origin redirects are followed up to 3 hops.
93
+ - **Deterministic Caps:** Enforces a `2.5s` global crawl budget, up to `10` routes, `5` scripts, and `1 MB` per response body.
94
+
95
+ ## CLI Options & Exit Codes
96
+
97
+ ### Options
98
+
99
+ | Flag | Description |
100
+ | :--- | :--- |
101
+ | `-p, --port <number>` | Check a specific TCP port (`1-65535`) instead of auto-detecting |
102
+ | `-h, --help` | Show CLI usage information |
103
+ | `-v, --version` | Print the package version |
104
+
105
+ ### Exit Codes
106
+
107
+ | Code | Meaning |
108
+ | :--- | :--- |
109
+ | `0` | All sanity checks passed (including non-fatal warnings) |
110
+ | `1` | Sanity check failed (`http_error`, `masked_error`, `secret_leak`) or no local HTTP server found |
111
+ | `2` | CLI usage error (invalid flag or invalid `--port` value) |
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../dist/cli.js';
3
+ process.exitCode = await main(process.argv.slice(2));
package/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export interface CliIO {
2
+ stdout: (msg: string) => void;
3
+ stderr: (msg: string) => void;
4
+ }
5
+ export declare function main(argv?: string[], io?: Partial<CliIO>): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readFileSync } from 'node:fs';
3
+ import { runVibesane, EXIT_CODES } from './index.js';
4
+ import { formatTerminalReport, shouldEnableColor } from './reporter/terminal-ui.js';
5
+ const HELP_TEXT = [
6
+ 'Usage: vibesane [options]',
7
+ '',
8
+ 'Options:',
9
+ ' -p, --port <number> Target local port (1-65535)',
10
+ ' -h, --help Show help',
11
+ ' -v, --version Show version',
12
+ ].join('\n');
13
+ function getVersion() {
14
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
+ return pkg.version;
16
+ }
17
+ export async function main(argv = process.argv.slice(2), io = {}) {
18
+ const stdout = io.stdout ?? ((msg) => process.stdout.write(`${msg}\n`));
19
+ const stderr = io.stderr ?? ((msg) => process.stderr.write(`${msg}\n`));
20
+ const stopIdx = argv.indexOf('--');
21
+ const activeArgs = stopIdx === -1 ? argv : argv.slice(0, stopIdx);
22
+ if (activeArgs.includes('--help') || activeArgs.includes('-h')) {
23
+ stdout(HELP_TEXT);
24
+ return EXIT_CODES.OK;
25
+ }
26
+ if (activeArgs.includes('--version') || activeArgs.includes('-v')) {
27
+ stdout(getVersion());
28
+ return EXIT_CODES.OK;
29
+ }
30
+ let values;
31
+ try {
32
+ const parsed = parseArgs({
33
+ args: argv,
34
+ strict: true,
35
+ allowPositionals: false,
36
+ options: {
37
+ port: { type: 'string', short: 'p', multiple: true },
38
+ help: { type: 'boolean', short: 'h' },
39
+ version: { type: 'boolean', short: 'v' },
40
+ },
41
+ });
42
+ values = parsed.values;
43
+ }
44
+ catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ stderr(`vibesane: error: ${message}`);
47
+ return EXIT_CODES.USAGE_ERROR;
48
+ }
49
+ if (values.help) {
50
+ stdout(HELP_TEXT);
51
+ return EXIT_CODES.OK;
52
+ }
53
+ if (values.version) {
54
+ stdout(getVersion());
55
+ return EXIT_CODES.OK;
56
+ }
57
+ let portNum;
58
+ if (values.port !== undefined) {
59
+ if (values.port.length > 1) {
60
+ stderr('vibesane: error: option --port cannot be specified multiple times');
61
+ return EXIT_CODES.USAGE_ERROR;
62
+ }
63
+ const rawPort = values.port[0];
64
+ if (rawPort === undefined || !/^[1-9]\d*$/.test(rawPort)) {
65
+ stderr(`vibesane: error: invalid port "${rawPort ?? ''}" (expected integer 1-65535)`);
66
+ return EXIT_CODES.USAGE_ERROR;
67
+ }
68
+ portNum = Number(rawPort);
69
+ if (portNum < 1 || portNum > 65535) {
70
+ stderr(`vibesane: error: invalid port "${rawPort}" (expected integer 1-65535)`);
71
+ return EXIT_CODES.USAGE_ERROR;
72
+ }
73
+ }
74
+ const result = await runVibesane(portNum !== undefined ? { port: portNum } : {});
75
+ const useColor = io.stdout === undefined && shouldEnableColor();
76
+ const output = formatTerminalReport(result, { color: useColor });
77
+ stdout(output);
78
+ return result.ok ? EXIT_CODES.OK : EXIT_CODES.SANITY_FAILURE;
79
+ }
@@ -0,0 +1,24 @@
1
+ import { DEFAULT_PORTS, DEFAULT_TCP_TIMEOUT_MS, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_BODY_READ_TIMEOUT_MS, MAX_BODY_BYTES, assertValidPort, assertValidTimeout, readBoundedTextBody, probeTcpPort, detectServer, type LoopbackHost, type DetectionFailureReason, type PortDetectorOptions, type TcpProbeResult, type RootHttpSnapshot, type PortDetectionResult } from './scanner/port-detector.js';
2
+ import { redactSecret, redactPassword, redactConnectionUri, stripViteTimestampQuery, resolveBundledSourceFile, sanitizeSecretsInText, sanitizeErrorSnippet, extractViteErrorInfo, extractNextErrorInfo, detectMaskedError, scanSecrets, type SanitySeverity, type SanityIssueKind, type SanityIssue } from './scanner/security-scan.js';
3
+ import { DEFAULT_SCAN_BUDGET_MS, DEFAULT_ROUTE_TIMEOUT_MS, DEFAULT_SLOW_ROUTE_MS, DEFAULT_MAX_ROUTES, DEFAULT_MAX_SCRIPTS, DEFAULT_MAX_CONCURRENCY, MAX_REDIRECTS, isExcludedFrameworkScript, getScriptPriority, extractInternalLinks, extractCandidateScripts, selectScriptsToFetch, extractModuleImports, runSanityScan, type RouteScanItem, type SanityScanOptions, type SanityScanSummary } from './scanner/route-crawler.js';
4
+ import { AI_PROMPT_HEADER, SECRET_LEAK_INSTRUCTION, CODE_ERROR_INSTRUCTION, MAX_PROMPT_ERRORS, stripAnsiCodes, generateAiFixPrompt, type AiPromptIssue, type AiPromptInput } from './reporter/ai-prompt-gen.js';
5
+ import { shouldEnableColor, formatTerminalReport, type VibesaneReportInput, type TerminalReportOptions } from './reporter/terminal-ui.js';
6
+ export type AiPromptIssueInput = AiPromptIssue;
7
+ export { DEFAULT_PORTS, DEFAULT_TCP_TIMEOUT_MS, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_BODY_READ_TIMEOUT_MS, MAX_BODY_BYTES, assertValidPort, assertValidTimeout, readBoundedTextBody, probeTcpPort, detectServer, type LoopbackHost, type DetectionFailureReason, type PortDetectorOptions, type TcpProbeResult, type RootHttpSnapshot, type PortDetectionResult, redactSecret, redactPassword, redactConnectionUri, stripViteTimestampQuery, resolveBundledSourceFile, sanitizeSecretsInText, sanitizeErrorSnippet, extractViteErrorInfo, extractNextErrorInfo, detectMaskedError, scanSecrets, type SanitySeverity, type SanityIssueKind, type SanityIssue, DEFAULT_SCAN_BUDGET_MS, DEFAULT_ROUTE_TIMEOUT_MS, DEFAULT_SLOW_ROUTE_MS, DEFAULT_MAX_ROUTES, DEFAULT_MAX_SCRIPTS, DEFAULT_MAX_CONCURRENCY, MAX_REDIRECTS, isExcludedFrameworkScript, getScriptPriority, extractInternalLinks, extractCandidateScripts, selectScriptsToFetch, extractModuleImports, runSanityScan, type RouteScanItem, type SanityScanOptions, type SanityScanSummary, AI_PROMPT_HEADER, SECRET_LEAK_INSTRUCTION, CODE_ERROR_INSTRUCTION, MAX_PROMPT_ERRORS, stripAnsiCodes, generateAiFixPrompt, type AiPromptIssue, type AiPromptInput, shouldEnableColor, formatTerminalReport, type VibesaneReportInput, type TerminalReportOptions, };
8
+ export declare const EXIT_CODES: {
9
+ readonly OK: 0;
10
+ readonly SANITY_FAILURE: 1;
11
+ readonly USAGE_ERROR: 2;
12
+ };
13
+ export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
14
+ export interface VibesaneOptions extends PortDetectorOptions, SanityScanOptions {
15
+ }
16
+ export interface VibesaneResult extends PortDetectionResult {
17
+ routes: RouteScanItem[];
18
+ scannedScripts: string[];
19
+ skippedRoutes: string[];
20
+ issues: SanityIssue[];
21
+ aiPrompt: string | null;
22
+ message: string;
23
+ }
24
+ export declare function runVibesane(options?: VibesaneOptions): Promise<VibesaneResult>;
package/dist/index.js ADDED
@@ -0,0 +1,121 @@
1
+ import { DEFAULT_PORTS, DEFAULT_TCP_TIMEOUT_MS, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_BODY_READ_TIMEOUT_MS, MAX_BODY_BYTES, assertValidPort, assertValidTimeout, readBoundedTextBody, probeTcpPort, detectServer, } from './scanner/port-detector.js';
2
+ import { redactSecret, redactPassword, redactConnectionUri, stripViteTimestampQuery, resolveBundledSourceFile, sanitizeSecretsInText, sanitizeErrorSnippet, extractViteErrorInfo, extractNextErrorInfo, detectMaskedError, scanSecrets, } from './scanner/security-scan.js';
3
+ import { DEFAULT_SCAN_BUDGET_MS, DEFAULT_ROUTE_TIMEOUT_MS, DEFAULT_SLOW_ROUTE_MS, DEFAULT_MAX_ROUTES, DEFAULT_MAX_SCRIPTS, DEFAULT_MAX_CONCURRENCY, MAX_REDIRECTS, isExcludedFrameworkScript, getScriptPriority, extractInternalLinks, extractCandidateScripts, selectScriptsToFetch, extractModuleImports, runSanityScan, } from './scanner/route-crawler.js';
4
+ import { AI_PROMPT_HEADER, SECRET_LEAK_INSTRUCTION, CODE_ERROR_INSTRUCTION, MAX_PROMPT_ERRORS, stripAnsiCodes, generateAiFixPrompt, } from './reporter/ai-prompt-gen.js';
5
+ import { shouldEnableColor, formatTerminalReport, } from './reporter/terminal-ui.js';
6
+ export { DEFAULT_PORTS, DEFAULT_TCP_TIMEOUT_MS, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_BODY_READ_TIMEOUT_MS, MAX_BODY_BYTES, assertValidPort, assertValidTimeout, readBoundedTextBody, probeTcpPort, detectServer, redactSecret, redactPassword, redactConnectionUri, stripViteTimestampQuery, resolveBundledSourceFile, sanitizeSecretsInText, sanitizeErrorSnippet, extractViteErrorInfo, extractNextErrorInfo, detectMaskedError, scanSecrets, DEFAULT_SCAN_BUDGET_MS, DEFAULT_ROUTE_TIMEOUT_MS, DEFAULT_SLOW_ROUTE_MS, DEFAULT_MAX_ROUTES, DEFAULT_MAX_SCRIPTS, DEFAULT_MAX_CONCURRENCY, MAX_REDIRECTS, isExcludedFrameworkScript, getScriptPriority, extractInternalLinks, extractCandidateScripts, selectScriptsToFetch, extractModuleImports, runSanityScan, AI_PROMPT_HEADER, SECRET_LEAK_INSTRUCTION, CODE_ERROR_INSTRUCTION, MAX_PROMPT_ERRORS, stripAnsiCodes, generateAiFixPrompt, shouldEnableColor, formatTerminalReport, };
7
+ export const EXIT_CODES = {
8
+ OK: 0,
9
+ SANITY_FAILURE: 1,
10
+ USAGE_ERROR: 2,
11
+ };
12
+ function validateSanityScanOptions(options) {
13
+ if (options.scanBudgetMs !== undefined) {
14
+ assertValidTimeout(options.scanBudgetMs, 'scanBudgetMs');
15
+ }
16
+ if (options.routeTimeoutMs !== undefined) {
17
+ assertValidTimeout(options.routeTimeoutMs, 'routeTimeoutMs');
18
+ }
19
+ if (options.slowRouteMs !== undefined) {
20
+ assertValidTimeout(options.slowRouteMs, 'slowRouteMs');
21
+ }
22
+ if (options.maxRoutes !== undefined &&
23
+ (!Number.isInteger(options.maxRoutes) || options.maxRoutes < 1)) {
24
+ throw new RangeError(`Invalid maxRoutes "${options.maxRoutes}": expected integer >= 1`);
25
+ }
26
+ if (options.maxScripts !== undefined &&
27
+ (!Number.isInteger(options.maxScripts) || options.maxScripts < 0)) {
28
+ throw new RangeError(`Invalid maxScripts "${options.maxScripts}": expected integer >= 0`);
29
+ }
30
+ if (options.maxConcurrency !== undefined &&
31
+ (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1)) {
32
+ throw new RangeError(`Invalid maxConcurrency "${options.maxConcurrency}": expected integer >= 1`);
33
+ }
34
+ }
35
+ function formatScanMessage(detection, ok, errors, warnings, skippedRoutes) {
36
+ const alsoSuffix = detection.alsoActivePorts.length > 0
37
+ ? ` (also active: ${detection.alsoActivePorts.join(', ')})`
38
+ : '';
39
+ const baseLine = `vibesane: connected to ${detection.url} (${detection.elapsedMs}ms)${alsoSuffix}`;
40
+ if (ok) {
41
+ const lines = [baseLine];
42
+ for (const w of warnings) {
43
+ lines.push(`warning [${w.kind}]: ${stripViteTimestampQuery(w.detail)}`);
44
+ }
45
+ if (skippedRoutes.length > 0) {
46
+ lines.push(`info: ${skippedRoutes.length} route(s) not checked (time budget reached): ${skippedRoutes.join(', ')}`);
47
+ }
48
+ return lines.join('\n');
49
+ }
50
+ const lines = [
51
+ `vibesane: sanity check failed on ${detection.url} (${errors.length} error(s))`,
52
+ ];
53
+ for (const e of errors) {
54
+ const cleanDetail = stripViteTimestampQuery(e.detail);
55
+ lines.push(`error [${e.kind}]: ${cleanDetail}`);
56
+ }
57
+ for (const w of warnings) {
58
+ lines.push(`warning [${w.kind}]: ${stripViteTimestampQuery(w.detail)}`);
59
+ }
60
+ if (skippedRoutes.length > 0) {
61
+ lines.push(`info: ${skippedRoutes.length} route(s) not checked (time budget reached): ${skippedRoutes.join(', ')}`);
62
+ }
63
+ return lines.join('\n');
64
+ }
65
+ export async function runVibesane(options = {}) {
66
+ validateSanityScanOptions(options);
67
+ const detection = await detectServer(options);
68
+ if (!detection.ok || detection.port === null || !detection.host || !detection.rootResponse) {
69
+ let message;
70
+ if (detection.reason === 'http_timeout') {
71
+ const httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
72
+ const label = httpTimeoutMs % 1000 === 0 ? `${httpTimeoutMs / 1000}s` : `${httpTimeoutMs}ms`;
73
+ message = `vibesane: port ${detection.port} accepted the connection but took >${label} to respond (still compiling?)`;
74
+ }
75
+ else if (detection.reason === 'non_http') {
76
+ message = `vibesane: port ${detection.port} is open but did not respond to HTTP`;
77
+ }
78
+ else {
79
+ if (detection.checkedPorts.length === 1) {
80
+ message = `vibesane: no active server found on port ${detection.checkedPorts[0]}`;
81
+ }
82
+ else {
83
+ message = `vibesane: no active server found on ports ${detection.checkedPorts.join(', ')}`;
84
+ }
85
+ }
86
+ return {
87
+ ...detection,
88
+ routes: [],
89
+ scannedScripts: [],
90
+ skippedRoutes: [],
91
+ issues: [],
92
+ aiPrompt: null,
93
+ message,
94
+ };
95
+ }
96
+ const scan = await runSanityScan(detection.host, detection.port, detection.rootResponse, options);
97
+ const normalizedIssues = scan.issues.map((issue) => ({
98
+ ...issue,
99
+ route: stripViteTimestampQuery(issue.route),
100
+ }));
101
+ const errors = normalizedIssues.filter((i) => i.severity === 'error');
102
+ const warnings = normalizedIssues.filter((i) => i.severity === 'warning');
103
+ const ok = errors.length === 0;
104
+ const message = formatScanMessage(detection, ok, errors, warnings, scan.skippedRoutes);
105
+ const aiPrompt = generateAiFixPrompt({
106
+ ok,
107
+ port: detection.port,
108
+ url: detection.url,
109
+ issues: normalizedIssues,
110
+ });
111
+ return {
112
+ ...detection,
113
+ ok,
114
+ routes: scan.routes,
115
+ scannedScripts: scan.scannedScripts,
116
+ skippedRoutes: scan.skippedRoutes,
117
+ issues: normalizedIssues,
118
+ aiPrompt,
119
+ message,
120
+ };
121
+ }
@@ -0,0 +1,18 @@
1
+ import { stripViteTimestampQuery, type SanityIssue } from '../scanner/security-scan.js';
2
+ export { stripViteTimestampQuery };
3
+ export declare const AI_PROMPT_HEADER = "AI FIX PROMPT (copy & paste into Cursor / Claude Code / Gemini CLI):";
4
+ export declare const SECRET_LEAK_INSTRUCTION = "Move this key to a server-only environment variable (never NEXT_PUBLIC_ / VITE_) and rotate it if it was ever committed or deployed.";
5
+ export declare const CODE_ERROR_INSTRUCTION = "Fix only the error pointed out above; do not refactor unrelated code.";
6
+ export declare const MAX_PROMPT_ERRORS = 10;
7
+ export type AiPromptIssue = SanityIssue & {
8
+ codeFrame?: string;
9
+ };
10
+ export interface AiPromptInput {
11
+ ok: boolean;
12
+ port: number | null;
13
+ url?: string;
14
+ issues: readonly AiPromptIssue[];
15
+ }
16
+ export declare function stripAnsiCodes(value: string): string;
17
+ export declare function detailContainsRoute(detail: string, route: string): boolean;
18
+ export declare function generateAiFixPrompt(input: AiPromptInput): string | null;
@@ -0,0 +1,101 @@
1
+ import { stripViteTimestampQuery, } from '../scanner/security-scan.js';
2
+ import { toAsciiSafe } from '../text/ascii.js';
3
+ export { stripViteTimestampQuery };
4
+ export const AI_PROMPT_HEADER = 'AI FIX PROMPT (copy & paste into Cursor / Claude Code / Gemini CLI):';
5
+ export const SECRET_LEAK_INSTRUCTION = 'Move this key to a server-only environment variable (never NEXT_PUBLIC_ / VITE_) and rotate it if it was ever committed or deployed.';
6
+ export const CODE_ERROR_INSTRUCTION = 'Fix only the error pointed out above; do not refactor unrelated code.';
7
+ export const MAX_PROMPT_ERRORS = 10;
8
+ const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
9
+ export function stripAnsiCodes(value) {
10
+ if (!value) {
11
+ return '';
12
+ }
13
+ return value.replace(ANSI_ESCAPE_RE, '');
14
+ }
15
+ export function detailContainsRoute(detail, route) {
16
+ if (!detail || !route) {
17
+ return false;
18
+ }
19
+ const escaped = route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ return new RegExp(`(?:^|\\s)${escaped}(?=$|\\s|:|\\)|,)`).test(detail);
21
+ }
22
+ function formatIndentedCodeFrame(rawFrame, maxLines = 10) {
23
+ const clean = toAsciiSafe(stripAnsiCodes(rawFrame)).replace(/\r\n/g, '\n').trimEnd();
24
+ if (!clean) {
25
+ return '';
26
+ }
27
+ const lines = clean
28
+ .split('\n')
29
+ .map((line) => line.trimEnd())
30
+ .filter((line, idx, arr) => !(line === '' && (idx === 0 || idx === arr.length - 1)))
31
+ .slice(0, maxLines);
32
+ if (lines.length === 0) {
33
+ return '';
34
+ }
35
+ return lines.map((line) => ` ${line}`).join('\n');
36
+ }
37
+ export function generateAiFixPrompt(input) {
38
+ if (input.port === null || !Array.isArray(input.issues) || input.issues.length === 0) {
39
+ return null;
40
+ }
41
+ const errorIssues = input.issues.filter((issue) => issue.severity === 'error');
42
+ if (errorIssues.length === 0) {
43
+ return null;
44
+ }
45
+ const uniqueErrors = [];
46
+ const seenKeys = new Set();
47
+ for (const issue of errorIssues) {
48
+ const cleanRoute = toAsciiSafe(stripViteTimestampQuery(stripAnsiCodes(issue.route).trim())) || '/';
49
+ const cleanDetail = toAsciiSafe(stripViteTimestampQuery(stripAnsiCodes(issue.detail).trim()));
50
+ const dedupeKey = `${issue.kind}::${cleanRoute}::${cleanDetail}`;
51
+ if (seenKeys.has(dedupeKey)) {
52
+ continue;
53
+ }
54
+ seenKeys.add(dedupeKey);
55
+ const formattedFrame = issue.codeFrame
56
+ ? formatIndentedCodeFrame(issue.codeFrame, 10)
57
+ : '';
58
+ uniqueErrors.push({
59
+ kind: issue.kind,
60
+ route: cleanRoute,
61
+ detail: cleanDetail,
62
+ ...(formattedFrame ? { codeFrame: formattedFrame } : {}),
63
+ });
64
+ }
65
+ if (uniqueErrors.length === 0) {
66
+ return null;
67
+ }
68
+ const shownErrors = uniqueErrors.slice(0, MAX_PROMPT_ERRORS);
69
+ const remaining = uniqueErrors.length - shownErrors.length;
70
+ const lines = [AI_PROMPT_HEADER];
71
+ if (typeof input.url === 'string' && input.url.trim().length > 0) {
72
+ lines.push(`Target: ${toAsciiSafe(stripAnsiCodes(input.url.trim()))}`);
73
+ }
74
+ shownErrors.forEach((err, index) => {
75
+ if (detailContainsRoute(err.detail, err.route)) {
76
+ lines.push(`${index + 1}. [${err.kind}] ${err.detail}`);
77
+ }
78
+ else {
79
+ lines.push(`${index + 1}. [${err.kind}] ${err.route} - ${err.detail}`);
80
+ }
81
+ if (err.codeFrame) {
82
+ lines.push(err.codeFrame);
83
+ }
84
+ });
85
+ if (remaining > 0) {
86
+ lines.push(`...and ${remaining} more`);
87
+ }
88
+ const hasSecretLeak = uniqueErrors.some((err) => err.kind === 'secret_leak');
89
+ const hasCodeError = uniqueErrors.some((err) => err.kind === 'http_error' || err.kind === 'masked_error');
90
+ if (hasSecretLeak && hasCodeError) {
91
+ lines.push(`Instruction (secrets): ${SECRET_LEAK_INSTRUCTION}`);
92
+ lines.push(`Instruction (code): ${CODE_ERROR_INSTRUCTION}`);
93
+ }
94
+ else if (hasSecretLeak) {
95
+ lines.push(`Instruction: ${SECRET_LEAK_INSTRUCTION}`);
96
+ }
97
+ else if (hasCodeError) {
98
+ lines.push(`Instruction: ${CODE_ERROR_INSTRUCTION}`);
99
+ }
100
+ return toAsciiSafe(stripAnsiCodes(lines.join('\n')));
101
+ }
@@ -0,0 +1,14 @@
1
+ import { type AiPromptInput } from './ai-prompt-gen.js';
2
+ export interface VibesaneReportInput extends AiPromptInput {
3
+ message: string;
4
+ reason?: string;
5
+ skippedRoutes?: readonly string[];
6
+ aiPrompt?: string | null;
7
+ }
8
+ export interface TerminalReportOptions {
9
+ color?: boolean;
10
+ isTTY?: boolean;
11
+ env?: NodeJS.ProcessEnv;
12
+ }
13
+ export declare function shouldEnableColor(customOpt?: boolean, isTTY?: boolean, env?: NodeJS.ProcessEnv): boolean;
14
+ export declare function formatTerminalReport(result: VibesaneReportInput, options?: TerminalReportOptions): string;
@@ -0,0 +1,103 @@
1
+ import { styleText } from 'node:util';
2
+ import { toAsciiSafe } from '../text/ascii.js';
3
+ import { AI_PROMPT_HEADER, generateAiFixPrompt, stripAnsiCodes, } from './ai-prompt-gen.js';
4
+ export function shouldEnableColor(customOpt, isTTY = Boolean(process.stdout?.isTTY), env = process.env) {
5
+ if (env?.NO_COLOR !== undefined && env.NO_COLOR !== '') {
6
+ return false;
7
+ }
8
+ if (typeof customOpt === 'boolean') {
9
+ return customOpt;
10
+ }
11
+ return Boolean(isTTY);
12
+ }
13
+ function formatSummaryCodeFrame(rawFrame, maxLines = 10) {
14
+ const clean = toAsciiSafe(stripAnsiCodes(rawFrame)).replace(/\r\n/g, '\n').trimEnd();
15
+ if (!clean) {
16
+ return [];
17
+ }
18
+ return clean
19
+ .split('\n')
20
+ .map((line) => line.trimEnd())
21
+ .filter((line, idx, arr) => !(line === '' && (idx === 0 || idx === arr.length - 1)))
22
+ .slice(0, maxLines)
23
+ .map((line) => ` ${line}`);
24
+ }
25
+ function injectMissingCodeFrames(summaryText, issues) {
26
+ const issuesWithFrames = issues.filter((i) => i.severity === 'error' && typeof i.codeFrame === 'string' && i.codeFrame.trim().length > 0);
27
+ if (issuesWithFrames.length === 0) {
28
+ return summaryText;
29
+ }
30
+ const lines = summaryText.split('\n');
31
+ const out = [];
32
+ const usedIssueIndices = new Set();
33
+ for (const line of lines) {
34
+ out.push(line);
35
+ if (!line.startsWith('error [')) {
36
+ continue;
37
+ }
38
+ const matchIdx = issuesWithFrames.findIndex((issue, idx) => {
39
+ if (usedIssueIndices.has(idx)) {
40
+ return false;
41
+ }
42
+ return line.includes(`[${issue.kind}]`) && line.includes(issue.route);
43
+ });
44
+ if (matchIdx !== -1) {
45
+ usedIssueIndices.add(matchIdx);
46
+ const frameLines = formatSummaryCodeFrame(issuesWithFrames[matchIdx].codeFrame);
47
+ const firstFrameLine = frameLines[0]?.trim();
48
+ if (firstFrameLine && !summaryText.includes(firstFrameLine)) {
49
+ out.push(...frameLines);
50
+ }
51
+ }
52
+ }
53
+ return out.join('\n');
54
+ }
55
+ function styleSummaryLines(summaryText, ok) {
56
+ return summaryText
57
+ .split('\n')
58
+ .map((line) => {
59
+ if (ok && line.startsWith('vibesane: connected to ')) {
60
+ return styleText('green', line);
61
+ }
62
+ if (!ok && (line.startsWith('vibesane: sanity check failed') || line.startsWith('error '))) {
63
+ return styleText('red', line);
64
+ }
65
+ if (line.startsWith('warning ')) {
66
+ return styleText('yellow', line);
67
+ }
68
+ return line;
69
+ })
70
+ .join('\n');
71
+ }
72
+ export function formatTerminalReport(result, options = {}) {
73
+ const useColor = shouldEnableColor(options.color, options.isTTY, options.env);
74
+ const hasErrors = Array.isArray(result.issues) &&
75
+ result.issues.some((issue) => issue.severity === 'error');
76
+ const safeMessage = toAsciiSafe(result.message);
77
+ if (result.port === null || (!result.ok && !hasErrors)) {
78
+ return useColor ? styleText('red', safeMessage) : safeMessage;
79
+ }
80
+ if (result.ok) {
81
+ if (!useColor) {
82
+ return safeMessage;
83
+ }
84
+ return styleSummaryLines(safeMessage, true);
85
+ }
86
+ let summarySection = safeMessage;
87
+ let existingPromptSection = null;
88
+ const headerIndex = safeMessage.indexOf(AI_PROMPT_HEADER);
89
+ if (headerIndex !== -1) {
90
+ summarySection = safeMessage.slice(0, headerIndex).trimEnd();
91
+ existingPromptSection = safeMessage.slice(headerIndex);
92
+ }
93
+ summarySection = injectMissingCodeFrames(toAsciiSafe(summarySection), result.issues);
94
+ const renderedSummary = useColor
95
+ ? styleSummaryLines(summarySection, false)
96
+ : summarySection;
97
+ const promptBlock = existingPromptSection ??
98
+ (result.aiPrompt !== undefined ? result.aiPrompt : generateAiFixPrompt(result));
99
+ if (!promptBlock) {
100
+ return renderedSummary;
101
+ }
102
+ return `${renderedSummary}\n\n${toAsciiSafe(stripAnsiCodes(promptBlock))}`;
103
+ }