Remove live commands from CLI, delete src/live, add server-lost cleanup

1. CLI cleanup: removed live, poll, and wrap commands from bin/cli.js
   and the liveCli export from detect-antipatterns.mjs. These now live
   exclusively in the skill scripts (node scripts_path/live-server.mjs).

2. Deleted src/live/: server.mjs, poll.mjs, wrap.mjs, browser.js,
   protocol.mjs. The source of truth is now source/skills/impeccable/
   scripts/live-*.

3. Graceful server-lost handling: the browser tracks SSE reconnection
   attempts (max 5). After exhausting retries, it cleans up the UI:
   hides the bar, highlight, and cycler, shows a "Live server
   disconnected" toast, resets state to IDLE. This handles agent
   crashes, server kills, and network issues without leaving the
   browser stuck in a "Generating..." state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-12 19:25:29 -07:00
co-authored by Claude Opus 4.6
parent 5bad08723d
commit 7011a523e0
19 changed files with 348 additions and 2269 deletions
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
+29 -2
View File
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
-19
View File
@@ -5,8 +5,6 @@
*
* Usage:
* npx impeccable detect [file-or-dir-or-url...]
* npx impeccable live [--port=PORT]
* npx impeccable live stop
* npx impeccable skills help|install|update
* npx impeccable --help
*/
@@ -24,11 +22,6 @@ if (!command || command === '--help' || command === '-h') {
Commands:
detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues
live [--port=PORT] Start live variant server (element picker + variant cycling)
live stop Stop a running live server
poll Wait for a browser event from the live server
poll --reply <id> <status> Reply to a pending event (done, error)
wrap --id ID --count N --query Q Find element in source and create variant wrapper
skills help List all available skills and commands
skills install Install impeccable skills into your project
skills update Update skills to the latest version
@@ -52,18 +45,6 @@ if (command === 'detect') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { detectCli } = await import('../src/detect-antipatterns.mjs');
await detectCli();
} else if (command === 'live') {
// Delegate to the self-contained skill script (also works via node scripts_path/live-server.mjs)
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
await import('../source/skills/impeccable/scripts/live-server.mjs');
} else if (command === 'poll') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { pollCli } = await import('../source/skills/impeccable/scripts/live-poll.mjs');
await pollCli();
} else if (command === 'wrap') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { wrapCli } = await import('../source/skills/impeccable/scripts/live-wrap.mjs');
await wrapCli();
} else if (command === 'skills') {
const { run } = await import('./commands/skills.mjs');
await run(args.slice(1));
@@ -870,11 +870,14 @@
// ---------------------------------------------------------------------------
let evtSource = null;
let sseRetries = 0;
const SSE_MAX_RETRIES = 5;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
sseRetries = 0; // reset on any successful message
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'connected':
@@ -918,11 +921,35 @@
};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
sseRetries++;
if (sseRetries <= SSE_MAX_RETRIES) {
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
return; // EventSource auto-reconnects
}
// Server is gone. Clean up gracefully.
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
evtSource.close();
evtSource = null;
handleServerLost();
};
}
/** Server died or became unreachable. Reset UI to a clean state. */
function handleServerLost() {
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
}
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'IDLE';
}
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
-10
View File
@@ -3442,15 +3442,6 @@ async function main() {
process.exit(0);
}
// ---------------------------------------------------------------------------
// Live detection server
// ---------------------------------------------------------------------------
async function liveCli() {
const { startLiveServer } = await import('./live/server.mjs');
await startLiveServer();
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
@@ -3475,7 +3466,6 @@ export {
buildImportGraph, resolveImport,
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
main as detectCli,
liveCli,
};
// @browser-strip-end
-1220
View File
File diff suppressed because it is too large Load Diff
-123
View File
@@ -1,123 +0,0 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
-79
View File
@@ -1,79 +0,0 @@
/**
* Shared protocol constants and validation for the live variant mode.
* Imported by both server.mjs and poll.mjs.
*/
// Browser → Server event types
export const EVENT = Object.freeze({
GENERATE: 'generate',
ACCEPT: 'accept',
DISCARD: 'discard',
EXIT: 'exit',
});
// Server → Browser message types
export const MSG = Object.freeze({
AUTH_OK: 'auth_ok',
AUTH_FAIL: 'auth_fail',
GENERATING: 'generating',
DONE: 'done',
ERROR: 'error',
});
// Poll return types (superset of EVENT — adds timeout)
export const POLL = Object.freeze({
...EVENT,
TIMEOUT: 'timeout',
});
// Commands that make sense for visual variant generation.
// Shown in the browser action panel dropdown.
export const VISUAL_ACTIONS = Object.freeze([
'impeccable', // default: freeform design pass
'bolder',
'quieter',
'distill',
'polish',
'typeset',
'colorize',
'layout',
'adapt',
'animate',
'delight',
'overdrive',
]);
/**
* Validate a browser event before queuing it for the agent.
* Returns null if valid, or an error string if not.
*/
export function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) {
return 'Missing or invalid message';
}
switch (msg.type) {
case EVENT.GENERATE:
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return `generate: invalid action "${msg.action}"`;
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || typeof msg.element !== 'object') return 'generate: missing element context';
if (!msg.element.outerHTML) return 'generate: element must include outerHTML';
return null;
case EVENT.ACCEPT:
if (!msg.id || typeof msg.id !== 'string') return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case EVENT.DISCARD:
if (!msg.id || typeof msg.id !== 'string') return 'discard: missing id';
return null;
case EVENT.EXIT:
return null;
default:
return `Unknown event type: "${msg.type}"`;
}
}
-496
View File
@@ -1,496 +0,0 @@
/**
* Live variant mode server.
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* manages a WebSocket connection to the browser, and exposes HTTP long-poll
* endpoints so the agent CLI can receive events and send replies.
*
* Start: npx impeccable live
* Stop: npx impeccable live stop
* Health: curl http://localhost:PORT/health
*/
import http from 'node:http';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import { validateEvent } from './protocol.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000; // 2 minutes
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
wsClients: new Set(),
// Queue: browser events waiting for the agent to poll
pendingEvents: [],
// Queue: agent poll response callbacks waiting for browser events
pendingPolls: [],
// Debounce timer for exit event (avoids false exits on transient disconnects)
exitTimer: null,
};
/** Push an event from the browser into the queue or resolve a waiting poll. */
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
const resolve = state.pendingPolls.shift();
resolve(event);
} else {
state.pendingEvents.push(event);
}
}
/** Broadcast a message to all authenticated WS clients. */
function broadcast(msg) {
const data = JSON.stringify(msg);
for (const ws of state.wsClients) {
if (ws.readyState === 1 /* OPEN */) {
ws.send(data);
}
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
const detectPath = path.join(__dirname, '..', 'detect-antipatterns-browser.js');
const livePath = path.join(__dirname, 'browser.js');
let detectScript = '';
try {
detectScript = fs.readFileSync(detectPath, 'utf-8');
} catch {
// Detection script is optional for the live variant server
}
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: Browser live script not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
// ---------------------------------------------------------------------------
// Check for .impeccable.md
// ---------------------------------------------------------------------------
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const pathname = url.pathname;
// --- Public endpoints (no auth) ---
if (pathname === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (pathname === '/detect.js' || pathname === '/') {
if (!detectScript) {
res.writeHead(404);
res.end('Detection script not available. Run npm run build:browser first.');
return;
}
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
if (pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
port: state.port,
mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.wsClients.size,
}));
return;
}
// Read a project file from disk (for no-HMR fallback: the browser fetches
// the raw source to inject variants when the dev server doesn't support HMR).
if (pathname === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
// Safety: must be within the project directory
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
const content = fs.readFileSync(absPath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
} catch {
res.writeHead(404); res.end('File not found');
}
return;
}
// --- Authenticated endpoints ---
const token = url.searchParams.get('token');
if (pathname === '/stop') {
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
if (pathname === '/poll') {
if (req.method === 'GET') {
handlePollGet(req, res, url);
} else if (req.method === 'POST') {
handlePollPost(req, res);
} else {
res.writeHead(405);
res.end('Method not allowed');
}
return;
}
res.writeHead(404);
res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Poll endpoints
// ---------------------------------------------------------------------------
/** GET /poll — agent blocks here until a browser event arrives. */
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
// If there's already an event queued, return it immediately
if (state.pendingEvents.length > 0) {
const event = state.pendingEvents.shift();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
return;
}
// Otherwise, wait for one
const timer = setTimeout(() => {
// Remove this callback from pendingPolls
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
// Clean up if the agent disconnects before we respond
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
/** POST /poll — agent replies to a pending browser event. */
function handlePollPost(req, res) {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
let msg;
try {
msg = JSON.parse(body);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser
broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// WebSocket handling
// ---------------------------------------------------------------------------
function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
let authenticated = false;
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}
// First message must be auth
if (!authenticated) {
if (msg.type === 'auth' && msg.token === state.token) {
authenticated = true;
state.wsClients.add(ws);
// Cancel any pending exit timer (client reconnected)
clearTimeout(state.exitTimer);
ws.send(JSON.stringify({
type: 'auth_ok',
hasProjectContext: hasProjectContext(),
}));
} else {
ws.send(JSON.stringify({ type: 'auth_fail', reason: 'Invalid token' }));
ws.close();
}
return;
}
// Validated browser events go to the agent poll queue
const error = validateEvent(msg);
if (error) {
ws.send(JSON.stringify({ type: 'error', message: error }));
return;
}
enqueueEvent(msg);
});
ws.on('close', () => {
state.wsClients.delete(ws);
// If all browser clients disconnected, debounce before signaling exit.
// The browser script reconnects within 3s, and HMR page reloads cause
// brief disconnects. Wait 8s to avoid false exits.
if (authenticated && state.wsClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.wsClients.size === 0) {
enqueueEvent({ type: 'exit' });
}
}, 8000);
}
});
ws.on('error', () => {
state.wsClients.delete(ws);
});
});
return wss;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
let wss = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch { /* ignore */ }
// Close all WebSocket connections
for (const ws of state.wsClients) {
try { ws.close(); } catch { /* ignore */ }
}
state.wsClients.clear();
// Resolve any pending polls with exit
for (const resolve of state.pendingPolls) {
resolve({ type: 'exit' });
}
state.pendingPolls.length = 0;
if (wss) { try { wss.close(); } catch { /* ignore */ } }
if (httpServer) { httpServer.close(); }
process.exit(0);
}
/**
* Start the live variant server.
* Called from liveCli() in detect-antipatterns.mjs.
*/
export async function startLiveServer({ port: requestedPort } = {}) {
const args = process.argv.slice(2);
const helpMode = args.includes('--help');
const stopMode = args.includes('stop');
const portArg = args.find(a => a.startsWith('--port='));
const parsedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
if (helpMode) {
console.log(`Usage: impeccable live [options]
Start the live variant mode server. Serves the browser overlay script and
bridges WebSocket connections from the browser to the agent poll CLI.
Commands:
live Start the server (default)
live stop Stop a running live server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help message
Endpoints:
/live.js Browser script for element picker + variant cycling
/detect.js Detection overlay script (backwards compatible)
/health Health check
/ws WebSocket endpoint for browser
/poll Long-poll endpoint for agent CLI`);
process.exit(0);
}
// Stop mode
if (stopMode) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) {
console.log(`Stopped live server on port ${info.port}.`);
}
} catch {
console.log('No running live server found.');
}
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
// Check if the process is actually running
try {
process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first: npx impeccable live stop');
process.exit(1);
} catch {
// Process is dead, clean up stale PID file
fs.unlinkSync(LIVE_PID_FILE);
}
} catch {
// No PID file — good
}
// Generate session token
state.token = randomUUID();
state.port = requestedPort || parsedPort || await findOpenPort();
// Load scripts
const { detectScript, liveScript } = loadBrowserScripts();
// Inject token and port into the live browser script
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
// Create HTTP server
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
// Attach WebSocket
wss = setupWebSocket(httpServer);
// Start listening
httpServer.listen(state.port, '127.0.0.1', () => {
// Write PID file with token so poll CLI can authenticate
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({
pid: process.pid,
port: state.port,
token: state.token,
}));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live variant server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject into your page source:`);
console.log(` <script src="${url}/live.js"><\/script>\n`);
console.log(`Or inject via browser console:`);
console.log(` const s = document.createElement('script');`);
console.log(` s.src = '${url}/live.js';`);
console.log(` document.head.appendChild(s);\n`);
console.log(`Agent poll: npx impeccable poll`);
console.log(`Stop: npx impeccable live stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
-298
View File
@@ -1,298 +0,0 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}