Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability
This commit is contained in:
@@ -9,6 +9,7 @@ import { getSessionMemory } from './session-memory.mjs';
|
||||
import { getContextManager } from './context-manager.mjs';
|
||||
import { getAllSkills, getSkill, executeSkill, getSkillListDisplay } from './skills.mjs';
|
||||
import { getDebugLogger } from './debug-logger.mjs';
|
||||
import { THEMES, getTheme, getThemeNames } from '../bin/themes.mjs';
|
||||
|
||||
/**
|
||||
* Process a slash command
|
||||
@@ -167,6 +168,38 @@ export async function processCommand(input) {
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// THEME (Vibe Upgrade)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
case '/theme': {
|
||||
if (!args) {
|
||||
// List themes
|
||||
const themeList = getThemeNames();
|
||||
const display = themeList.map(t => ` • ${t.id} - ${t.description}`).join('\n');
|
||||
return {
|
||||
handled: true,
|
||||
response: `🎨 **Available Themes**\n${display}\n\nUsage: /theme <name>`,
|
||||
action: 'show_themes'
|
||||
};
|
||||
}
|
||||
const themeId = args.toLowerCase().trim();
|
||||
if (!THEMES[themeId]) {
|
||||
const themeList = getThemeNames();
|
||||
const names = themeList.map(t => t.id).join(', ');
|
||||
return {
|
||||
handled: true,
|
||||
response: `❌ Unknown theme: "${themeId}"\nAvailable: ${names}`
|
||||
};
|
||||
}
|
||||
const theme = getTheme(themeId);
|
||||
return {
|
||||
handled: true,
|
||||
response: `🎨 **Theme Changed: ${theme.name}**\n${theme.description}`,
|
||||
action: 'set_theme',
|
||||
themeId: themeId
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HELP
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -31,11 +31,12 @@ const SYSTEM_PATHS = {
|
||||
* Task Type Detection
|
||||
*/
|
||||
const TASK_PATTERNS = {
|
||||
browser: /\b(website|browser|google|youtube|amazon|navigate|search online|open.*url|go to.*\.com|fill.*form|click.*button)\b/i,
|
||||
browser: /\b(website|browser|google|youtube|amazon|navigate|search online|search\b|open.*url|go to.*\.com|fill.*form|click.*button|chrome|chromium|edge|msedge|firefox)\b/i,
|
||||
desktop: /\b(open.*app|launch|click.*menu|type.*text|press.*key|screenshot|notepad|paint|calculator|telegram|discord)\b/i,
|
||||
code: /\b(write.*code|create.*file|function|class|module|implement|code.*for|script.*for)\b/i,
|
||||
file: /\b(create.*file|write.*file|save.*to|read.*file|edit.*file|delete.*file|rename)\b/i,
|
||||
shell: /\b(run.*command|terminal|shell|npm|node|pip|git|docker)\b/i,
|
||||
server: /\b(server|service|daemon|port|localhost|endpoint|api|health|status|logs|restart|start|stop|deploy|pm2|nginx|apache|systemctl)\b/i,
|
||||
query: /\b(what|how|why|explain|tell me|describe|list|show me)\b/i
|
||||
};
|
||||
|
||||
@@ -92,17 +93,23 @@ export async function executeAny(command, options = {}) {
|
||||
psCommand = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args.split(/\s+/).filter(Boolean)];
|
||||
}
|
||||
} else {
|
||||
// Need to extract script and add proper flags
|
||||
if (match) {
|
||||
const argsStr = match[2] || '';
|
||||
// Better regex to handle arguments with spaces inside quotes
|
||||
const args = argsStr.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
||||
const cleanArgs = args.map(a => a.startsWith('"') && a.endsWith('"') ? a.slice(1, -1) : a);
|
||||
// Normalize common forms:
|
||||
// - powershell bin/input.ps1 <args...>
|
||||
// - powershell "bin/input.ps1" <args...>
|
||||
// - bin/input.ps1 <args...>
|
||||
const psPrefixStripped = command.replace(/^powershell\s*/i, '');
|
||||
const inputPs1Match =
|
||||
psPrefixStripped.match(/["']?([^"'\s]*input\.ps1)["']?\s*(.*)/i) ||
|
||||
command.match(/["']?([^"'\s]*input\.ps1)["']?\s*(.*)/i);
|
||||
|
||||
if (inputPs1Match) {
|
||||
const argsStr = (inputPs1Match[2] || '').trim();
|
||||
const args = argsStr.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
||||
const cleanArgs = args.map(a => a.replace(/^["']|["']$/g, ''));
|
||||
psCommand = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SYSTEM_PATHS.inputPs1, ...cleanArgs];
|
||||
} else {
|
||||
// Just run the command as-is
|
||||
psCommand = ['-Command', command.replace(/^powershell\s*/i, '')];
|
||||
// Fall back to running whatever was provided (no input.ps1 detected)
|
||||
psCommand = ['-Command', psPrefixStripped];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,12 +164,12 @@ export function extractExecutables(response) {
|
||||
const executables = [];
|
||||
|
||||
// Match all code blocks
|
||||
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
|
||||
const codeBlockRegex = /```(\w*)(?::run)?\r?\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
|
||||
while ((match = codeBlockRegex.exec(response)) !== null) {
|
||||
const lang = match[1].toLowerCase();
|
||||
const code = match[2].trim();
|
||||
const code = match[2].replace(/\r/g, '').trim();
|
||||
|
||||
if (['bash', 'shell', 'powershell', 'ps1', 'cmd', 'sh'].includes(lang) || lang === '') {
|
||||
// Command to execute
|
||||
@@ -179,6 +186,35 @@ export function extractExecutables(response) {
|
||||
return executables;
|
||||
}
|
||||
|
||||
function fallbackExtractCommandsFromText(response) {
|
||||
const lines = String(response || '')
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const commands = [];
|
||||
for (const line of lines) {
|
||||
if (/^(powershell|pwsh|cmd|node|npm|pnpm|yarn|git)\b/i.test(line)) {
|
||||
commands.push(line);
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function sanitizeExtractedExecutables(executables) {
|
||||
const out = [];
|
||||
for (const e of Array.isArray(executables) ? executables : []) {
|
||||
if (!e || e.type !== 'command') continue;
|
||||
const cmd = String(e.content || '').replace(/\r/g, '').trim();
|
||||
if (!cmd) continue;
|
||||
// Prevent garbage “commands” from slipping through (covers many “half formatted” model outputs)
|
||||
if (!/^(powershell|pwsh|cmd|node|npm|pnpm|yarn|git)\b/i.test(cmd)) continue;
|
||||
out.push({ ...e, content: cmd });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response indicates task completion
|
||||
*/
|
||||
@@ -271,6 +307,63 @@ YOUR TASK:
|
||||
`;
|
||||
}
|
||||
|
||||
function buildTranslationPrompt(userRequest) {
|
||||
const request = String(userRequest || '');
|
||||
const looksWeb =
|
||||
/\bhttps?:\/\//i.test(request) ||
|
||||
/\bwww\./i.test(request) ||
|
||||
/\bgoogle\.com\b/i.test(request) ||
|
||||
/\b(search|browse|navigate|open\s+.*url|go\s+to)\b/i.test(request) ||
|
||||
/\b(edge|msedge|chrome|chrom(e|ium)|firefox)\b/i.test(request);
|
||||
|
||||
return [
|
||||
'You are the IQ Exchange Translation Layer.',
|
||||
'Translate the USER REQUEST into executable commands.',
|
||||
'',
|
||||
'You may output Desktop automation, Browser automation, and/or Server/Shell commands.',
|
||||
'',
|
||||
looksWeb
|
||||
? 'IMPORTANT: This is a WEB task. Use ONLY BROWSER (Playwright bridge) commands. Do NOT use Desktop (input.ps1) to open Edge/Chrome or type into the address bar.'
|
||||
: 'IMPORTANT: If the request is about websites/URLs/search, use ONLY BROWSER (Playwright bridge) commands (not Desktop).',
|
||||
'',
|
||||
'DESKTOP (Windows UIAutomation via input.ps1):',
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -File "${SYSTEM_PATHS.inputPs1}" <verb> <args...>`,
|
||||
'Verbs: open, waitfor, app_state, ocr, screenshot, focus, uiclick, uipress, type, key, hotkey, startmenu, mouse, click, drag',
|
||||
'Tip: Use `startmenu` for opening the Windows Start menu (more reliable than `key LWIN`).',
|
||||
'',
|
||||
'BROWSER (Playwright bridge):',
|
||||
`node "${SYSTEM_PATHS.playwrightBridge}" <command> <args...>`,
|
||||
'Commands: navigate <url>, click <selectorOrText>, fill <selectorOrLabel> <text>, press <key>, type <text>, url, title, content, elements, screenshot <file>, wait <selector> <ms>, close',
|
||||
'',
|
||||
'SERVER/SHELL:',
|
||||
'- Prefer safe read-only checks (status/logs) before restarts/deploys.',
|
||||
'- Use powershell/cmd as appropriate. Keep commands explicit.',
|
||||
'',
|
||||
'RULES:',
|
||||
'- Output ONLY one fenced code block.',
|
||||
'- One command per line.',
|
||||
'- Use the absolute paths exactly as shown in the templates above.',
|
||||
'- Include verification steps after actions when possible (app_state/ocr, url/title, health checks).',
|
||||
'- Prefer uiclick/uipress over mouse/click unless unavoidable.',
|
||||
'',
|
||||
`USER REQUEST: ${userRequest}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildStrictTranslationPrompt(userRequest, previousResponse = '') {
|
||||
const tail = String(previousResponse || '').slice(0, 800);
|
||||
return [
|
||||
buildTranslationPrompt(userRequest),
|
||||
'',
|
||||
'STRICT MODE (must follow):',
|
||||
'- Output EXACTLY one fenced code block and nothing else.',
|
||||
'- One command per line; no numbering; no commentary.',
|
||||
'- If no action is possible, output a single comment line explaining why (inside the code block).',
|
||||
'',
|
||||
tail ? `Previous (bad) output (for debugging):\n${tail}` : ''
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main IQ Exchange Class - The Universal Self-Healing Brain
|
||||
*/
|
||||
@@ -293,7 +386,25 @@ export class IQExchange {
|
||||
* This acts as the "Translation Layer"
|
||||
*/
|
||||
async translateRequest(userRequest) {
|
||||
const prompt = `
|
||||
const prompt = buildTranslationPrompt(userRequest);
|
||||
const response = await this.sendToAI(prompt);
|
||||
const extracted = sanitizeExtractedExecutables(extractExecutables(response));
|
||||
if (extracted.length > 0) return extracted;
|
||||
|
||||
const fallback = fallbackExtractCommandsFromText(response);
|
||||
if (fallback.length > 0) {
|
||||
return sanitizeExtractedExecutables(fallback.map((cmd) => ({ type: 'command', content: cmd, lang: '' })));
|
||||
}
|
||||
|
||||
// Retry once in strict mode (models sometimes ignore formatting rules on first pass)
|
||||
const strictResponse = await this.sendToAI(buildStrictTranslationPrompt(userRequest, response));
|
||||
const strictExtracted = sanitizeExtractedExecutables(extractExecutables(strictResponse));
|
||||
if (strictExtracted.length > 0) return strictExtracted;
|
||||
|
||||
const strictFallback = fallbackExtractCommandsFromText(strictResponse);
|
||||
return sanitizeExtractedExecutables(strictFallback.map((cmd) => ({ type: 'command', content: cmd, lang: '' })));
|
||||
|
||||
const legacyPrompt = `
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
AVAILABLE TOOLS (WINDOWS AUTOMATION):
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -339,8 +450,8 @@ powershell bin/input.ps1 type "Hello World"
|
||||
\`\`\`
|
||||
`.trim();
|
||||
|
||||
const response = await this.sendToAI(prompt);
|
||||
return extractExecutables(response);
|
||||
const legacyResponse = await this.sendToAI(legacyPrompt);
|
||||
return extractExecutables(legacyResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
367
lib/prereq.mjs
Normal file
367
lib/prereq.mjs
Normal file
@@ -0,0 +1,367 @@
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
const runCheck = (cmd, args = [], env = process.env) => {
|
||||
try {
|
||||
const r = spawnSync(cmd, args, { stdio: 'ignore', shell: false, env });
|
||||
return r.status === 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const commandExists = (cmd, env = process.env) => {
|
||||
if (isWin) return runCheck('where', [cmd], env);
|
||||
return runCheck('which', [cmd], env);
|
||||
};
|
||||
|
||||
export const hasWinget = () => isWin && runCheck('winget', ['--version']);
|
||||
export const hasBrew = () => isMac && runCheck('brew', ['--version']);
|
||||
|
||||
export const hasGit = () => runCheck('git', ['--version']);
|
||||
export const hasRipgrep = () => runCheck('rg', ['--version']);
|
||||
export const hasVercel = () => runCheck('vercel', ['--version']);
|
||||
|
||||
export const hasQwenCli = () => {
|
||||
if (!isWin) return runCheck('qwen', ['--version']);
|
||||
if (runCheck('qwen.cmd', ['--version'])) return true;
|
||||
const appData = process.env.APPDATA || '';
|
||||
const cliPath = path.join(appData, 'npm', 'node_modules', '@qwen-code', 'qwen-code', 'cli.js');
|
||||
return fs.existsSync(cliPath);
|
||||
};
|
||||
|
||||
export const hasRustup = () => runCheck('rustup', ['--version']);
|
||||
export const hasCargo = () => runCheck('cargo', ['--version']);
|
||||
|
||||
const hasCcToolchain = () => {
|
||||
if (isWin) return true; // handled by MSVC build tools check
|
||||
// cc is the standard entry; gcc/clang are common.
|
||||
return runCheck('cc', ['--version']) || runCheck('gcc', ['--version']) || runCheck('clang', ['--version']);
|
||||
};
|
||||
|
||||
const hasPkgConfig = () => {
|
||||
if (isWin) return true;
|
||||
return runCheck('pkg-config', ['--version']);
|
||||
};
|
||||
|
||||
const hasXcodeCLT = () => {
|
||||
if (!isMac) return true;
|
||||
return runCheck('xcode-select', ['-p']);
|
||||
};
|
||||
|
||||
const cargoBinDir = () => {
|
||||
const home = process.env.USERPROFILE || process.env.HOME || '';
|
||||
if (!home) return null;
|
||||
const p = path.join(home, '.cargo', 'bin');
|
||||
return fs.existsSync(p) ? p : null;
|
||||
};
|
||||
|
||||
export const envWithCargoOnPath = (env = process.env) => {
|
||||
if (!isWin) return { ...env };
|
||||
const cargoBin = cargoBinDir();
|
||||
if (!cargoBin) return { ...env };
|
||||
const current = String(env.PATH || env.Path || '');
|
||||
if (current.toLowerCase().includes(cargoBin.toLowerCase())) return { ...env };
|
||||
return { ...env, PATH: `${cargoBin};${current}` };
|
||||
};
|
||||
|
||||
const findVsDevCmd = () => {
|
||||
if (!isWin) return null;
|
||||
const pf86 = process.env['ProgramFiles(x86)'] || '';
|
||||
const vswhere = path.join(pf86, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe');
|
||||
if (!fs.existsSync(vswhere)) return null;
|
||||
try {
|
||||
const r = spawnSync(vswhere, [
|
||||
'-latest',
|
||||
'-products', '*',
|
||||
'-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
|
||||
'-property', 'installationPath'
|
||||
], { encoding: 'utf8' });
|
||||
const installPath = String(r.stdout || '').trim();
|
||||
if (!installPath) return null;
|
||||
const vsDevCmd = path.join(installPath, 'Common7', 'Tools', 'VsDevCmd.bat');
|
||||
return fs.existsSync(vsDevCmd) ? vsDevCmd : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// On Windows, checking `link.exe` on PATH is unreliable (it may only be available via VsDevCmd).
|
||||
// Treat MSVC as present if VsDevCmd exists (VC tools installed).
|
||||
export const hasMsvcBuildTools = () => (isWin ? Boolean(findVsDevCmd()) : true);
|
||||
|
||||
const detectLinuxPkgManager = () => {
|
||||
if (isWin || isMac) return null;
|
||||
if (commandExists('apt-get')) return 'apt';
|
||||
if (commandExists('dnf')) return 'dnf';
|
||||
if (commandExists('yum')) return 'yum';
|
||||
if (commandExists('pacman')) return 'pacman';
|
||||
if (commandExists('zypper')) return 'zypper';
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildInstallPlan = (items, kind) => {
|
||||
return (items || [])
|
||||
.filter(i => i.kind === kind && !i.ok)
|
||||
.map(i => i.install)
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export function detectPrereqs() {
|
||||
const winget = hasWinget();
|
||||
const brew = hasBrew();
|
||||
const linuxPm = detectLinuxPkgManager();
|
||||
|
||||
const baseline = [
|
||||
{ id: 'qwen', label: 'Qwen CLI (AI)', ok: hasQwenCli(), kind: 'baseline' },
|
||||
{ id: 'git', label: 'Git (NanoDev/worktrees)', ok: hasGit(), kind: 'baseline' },
|
||||
{ id: 'rg', label: 'Ripgrep (fast search)', ok: hasRipgrep(), kind: 'baseline' },
|
||||
];
|
||||
|
||||
const optional = [
|
||||
{ id: 'vercel', label: 'Vercel CLI (deploy)', ok: hasVercel(), kind: 'optional' },
|
||||
];
|
||||
|
||||
const goose = [
|
||||
{ id: 'rustup', label: 'Rustup (Rust toolchain manager)', ok: hasRustup(), kind: 'goose' },
|
||||
{ id: 'cargo', label: 'Cargo (build Goose)', ok: hasCargo(), kind: 'goose' },
|
||||
...(isWin ? [{ id: 'msvc', label: 'MSVC Build Tools (C++ toolchain)', ok: hasMsvcBuildTools(), kind: 'goose' }] : []),
|
||||
...(!isWin ? [{ id: 'cc', label: 'C toolchain (cc/gcc/clang)', ok: hasCcToolchain(), kind: 'goose' }] : []),
|
||||
...(!isWin ? [{ id: 'pkgconfig', label: 'pkg-config (native deps)', ok: hasPkgConfig(), kind: 'goose' }] : []),
|
||||
...(isMac ? [{ id: 'xcode', label: 'Xcode Command Line Tools', ok: hasXcodeCLT(), kind: 'goose' }] : []),
|
||||
];
|
||||
|
||||
// Attach per-platform install tokens
|
||||
const withInstall = (item) => {
|
||||
if (item.ok) return { ...item, install: null };
|
||||
|
||||
// Windows
|
||||
if (isWin) {
|
||||
if (item.id === 'git' && winget) return { ...item, install: 'winget-git' };
|
||||
if (item.id === 'rg' && winget) return { ...item, install: 'winget-rg' };
|
||||
if (item.id === 'qwen') return { ...item, install: winget ? 'winget-qwen' : 'npm-qwen' };
|
||||
if (item.id === 'vercel') return { ...item, install: 'npm-vercel' };
|
||||
if (item.id === 'rustup' && winget) return { ...item, install: 'winget-rustup' };
|
||||
if (item.id === 'msvc' && winget) return { ...item, install: 'winget-vsbuildtools' };
|
||||
return { ...item, install: null };
|
||||
}
|
||||
|
||||
// macOS (Homebrew)
|
||||
if (isMac) {
|
||||
if (!brew) return { ...item, install: null, note: 'Install Homebrew first' };
|
||||
if (item.id === 'git') return { ...item, install: 'brew-git' };
|
||||
if (item.id === 'rg') return { ...item, install: 'brew-rg' };
|
||||
if (item.id === 'cc' || item.id === 'xcode') return { ...item, install: 'mac-xcode-clt' };
|
||||
if (item.id === 'pkgconfig') return { ...item, install: 'brew-pkg-config' };
|
||||
if (item.id === 'vercel') return { ...item, install: 'npm-vercel' };
|
||||
if (item.id === 'qwen') return { ...item, install: 'npm-qwen' };
|
||||
if (item.id === 'rustup') return { ...item, install: 'rustup-install' };
|
||||
return { ...item, install: null };
|
||||
}
|
||||
|
||||
// Linux (system pkg manager)
|
||||
if (linuxPm) {
|
||||
if (item.id === 'git') return { ...item, install: `linux-${linuxPm}-git` };
|
||||
if (item.id === 'rg') return { ...item, install: `linux-${linuxPm}-rg` };
|
||||
if (item.id === 'cc') return { ...item, install: `linux-${linuxPm}-build` };
|
||||
if (item.id === 'pkgconfig') return { ...item, install: `linux-${linuxPm}-pkgconfig` };
|
||||
if (item.id === 'vercel') return { ...item, install: 'npm-vercel' };
|
||||
if (item.id === 'qwen') return { ...item, install: 'npm-qwen' };
|
||||
if (item.id === 'rustup') return { ...item, install: 'rustup-install' };
|
||||
}
|
||||
|
||||
return { ...item, install: null };
|
||||
};
|
||||
|
||||
const items = [...baseline, ...optional, ...goose].map(withInstall);
|
||||
const missingBaseline = items.filter(i => i.kind === 'baseline' && !i.ok);
|
||||
const missingGoose = items.filter(i => i.kind === 'goose' && !i.ok);
|
||||
|
||||
return {
|
||||
platform: process.platform,
|
||||
winget,
|
||||
brew,
|
||||
linuxPackageManager: linuxPm,
|
||||
items,
|
||||
missingBaseline,
|
||||
missingGoose,
|
||||
baselinePlan: buildInstallPlan(items, 'baseline'),
|
||||
goosePlan: buildInstallPlan(items, 'goose'),
|
||||
optionalPlan: buildInstallPlan(items, 'optional'),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizeProgressOutput = (text) => {
|
||||
let out = String(text || '');
|
||||
|
||||
// Convert cursor-position updates commonly used by winget/progress bars into line boundaries.
|
||||
// Example sequences:
|
||||
// - ESC[0G (move cursor to column 0)
|
||||
// - ESC[G
|
||||
// - ESC[2K (clear line)
|
||||
out = out.replace(/\u001b\[[0-9;]*G/g, '\n');
|
||||
out = out.replace(/\u001b\[[0-9;]*K/g, '');
|
||||
|
||||
// Treat carriage returns as "new line" boundaries (best-effort log view).
|
||||
out = out.replace(/\r/g, '\n');
|
||||
|
||||
// Strip remaining ANSI control sequences to avoid weird artifacts in TUI log.
|
||||
out = out.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
|
||||
out = out.replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, '');
|
||||
|
||||
// Remove backspaces (rare but can appear in progress updates)
|
||||
out = out.replace(/\u0008+/g, '');
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const streamLines = (chunk, carry, onLine) => {
|
||||
const text = normalizeProgressOutput(carry + chunk);
|
||||
const parts = text.split('\n');
|
||||
const nextCarry = parts.pop() ?? '';
|
||||
for (const p of parts) {
|
||||
const line = p.trim();
|
||||
if (line.length) onLine(line);
|
||||
}
|
||||
return nextCarry;
|
||||
};
|
||||
|
||||
const execStreaming = (command, opts = {}, onLine = () => { }) => new Promise((resolve) => {
|
||||
const child = spawn(command, {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
shell: true,
|
||||
env: opts.env || process.env,
|
||||
});
|
||||
|
||||
let carry = '';
|
||||
let out = '';
|
||||
|
||||
const onData = (d) => {
|
||||
const s = d.toString();
|
||||
out += s;
|
||||
carry = streamLines(s, carry, onLine);
|
||||
};
|
||||
|
||||
child.stdout.on('data', onData);
|
||||
child.stderr.on('data', onData);
|
||||
child.on('close', (code) => {
|
||||
if (carry.trim()) onLine(carry.trimEnd());
|
||||
resolve({ success: code === 0, code: code || 0, output: out.trim() });
|
||||
});
|
||||
child.on('error', (e) => resolve({ success: false, code: 1, output: String(e?.message || e) }));
|
||||
});
|
||||
|
||||
export function buildInstallSteps(installTokens) {
|
||||
const tokens = Array.isArray(installTokens) ? installTokens.filter(Boolean) : [];
|
||||
const steps = [];
|
||||
|
||||
const add = (id, label, command, options = {}) => {
|
||||
steps.push({ id, label, command, ...options });
|
||||
};
|
||||
|
||||
const linuxPm = detectLinuxPkgManager();
|
||||
|
||||
for (const t of tokens) {
|
||||
if (t === 'winget-git') add('git', 'Install Git', 'winget install --id Git.Git -e --accept-source-agreements --accept-package-agreements', { interactive: false });
|
||||
else if (t === 'winget-rg') add('rg', 'Install Ripgrep', 'winget install --id BurntSushi.ripgrep.MSVC -e --accept-source-agreements --accept-package-agreements', { interactive: false });
|
||||
else if (t === 'winget-rustup') {
|
||||
add('rustup', 'Install Rustup', 'winget install --id Rustlang.Rustup -e --accept-source-agreements --accept-package-agreements', { interactive: false });
|
||||
add('rustup-default', 'Set Rust stable', 'rustup default stable', { interactive: false, envPatch: 'cargo' });
|
||||
}
|
||||
else if (t === 'winget-vsbuildtools') add('vsbuildtools', 'Install MSVC Build Tools (C++)', 'winget install --id Microsoft.VisualStudio.2022.BuildTools -e --accept-source-agreements --accept-package-agreements --override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"', { interactive: true });
|
||||
else if (t === 'winget-qwen') add('qwen', 'Install Qwen CLI (npm)', 'npm install -g @qwen-code/qwen-code', { interactive: false });
|
||||
else if (t === 'npm-qwen') add('qwen', 'Install Qwen CLI (npm)', 'npm install -g @qwen-code/qwen-code', { interactive: false });
|
||||
else if (t === 'npm-vercel') add('vercel', 'Install Vercel CLI (npm)', 'npm install -g vercel', { interactive: false });
|
||||
else if (t === 'brew-git') add('git', 'Install Git (brew)', 'brew install git', { interactive: true });
|
||||
else if (t === 'brew-rg') add('rg', 'Install Ripgrep (brew)', 'brew install ripgrep', { interactive: true });
|
||||
else if (t === 'brew-pkg-config') add('pkg-config', 'Install pkg-config (brew)', 'brew install pkg-config', { interactive: true });
|
||||
else if (t === 'mac-xcode-clt') add('xcode-clt', 'Install Xcode Command Line Tools', 'xcode-select --install', { interactive: true });
|
||||
else if (t === 'rustup-install') add('rustup', 'Install Rustup', 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y', { interactive: true });
|
||||
else if (t.startsWith('linux-') && linuxPm) {
|
||||
const wantGit = t.endsWith('-git');
|
||||
const wantRg = t.endsWith('-rg');
|
||||
const wantBuild = t.endsWith('-build');
|
||||
const wantPkg = t.endsWith('-pkgconfig');
|
||||
|
||||
if (linuxPm === 'apt') {
|
||||
if (wantGit) add('git', 'Install git (apt)', 'sudo apt-get update && sudo apt-get install -y git', { interactive: true });
|
||||
if (wantRg) add('ripgrep', 'Install ripgrep (apt)', 'sudo apt-get update && sudo apt-get install -y ripgrep', { interactive: true });
|
||||
if (wantBuild) add('build-essential', 'Install build-essential (apt)', 'sudo apt-get update && sudo apt-get install -y build-essential', { interactive: true });
|
||||
if (wantPkg) add('pkg-config', 'Install pkg-config + ssl dev (apt)', 'sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev', { interactive: true });
|
||||
} else if (linuxPm === 'dnf') {
|
||||
if (wantGit) add('git', 'Install git (dnf)', 'sudo dnf install -y git', { interactive: true });
|
||||
if (wantRg) add('ripgrep', 'Install ripgrep (dnf)', 'sudo dnf install -y ripgrep', { interactive: true });
|
||||
if (wantBuild) add('build', 'Install C toolchain (dnf)', 'sudo dnf install -y gcc gcc-c++ make', { interactive: true });
|
||||
if (wantPkg) add('pkg-config', 'Install pkgconf + openssl-devel (dnf)', 'sudo dnf install -y pkgconf-pkg-config openssl-devel', { interactive: true });
|
||||
} else if (linuxPm === 'yum') {
|
||||
if (wantGit) add('git', 'Install git (yum)', 'sudo yum install -y git', { interactive: true });
|
||||
if (wantRg) add('ripgrep', 'Install ripgrep (yum)', 'sudo yum install -y ripgrep', { interactive: true });
|
||||
if (wantBuild) add('build', 'Install C toolchain (yum)', 'sudo yum install -y gcc gcc-c++ make', { interactive: true });
|
||||
if (wantPkg) add('pkg-config', 'Install pkgconfig + openssl-devel (yum)', 'sudo yum install -y pkgconfig openssl-devel', { interactive: true });
|
||||
} else if (linuxPm === 'pacman') {
|
||||
if (wantGit) add('git', 'Install git (pacman)', 'sudo pacman -Sy --noconfirm git', { interactive: true });
|
||||
if (wantRg) add('ripgrep', 'Install ripgrep (pacman)', 'sudo pacman -Sy --noconfirm ripgrep', { interactive: true });
|
||||
if (wantBuild) add('base-devel', 'Install base-devel (pacman)', 'sudo pacman -Sy --noconfirm base-devel', { interactive: true });
|
||||
if (wantPkg) add('pkg-config', 'Install pkgconf + openssl (pacman)', 'sudo pacman -Sy --noconfirm pkgconf openssl', { interactive: true });
|
||||
} else if (linuxPm === 'zypper') {
|
||||
if (wantGit) add('git', 'Install git (zypper)', 'sudo zypper --non-interactive install git', { interactive: true });
|
||||
if (wantRg) add('ripgrep', 'Install ripgrep (zypper)', 'sudo zypper --non-interactive install ripgrep', { interactive: true });
|
||||
if (wantBuild) add('build', 'Install gcc/make (zypper)', 'sudo zypper --non-interactive install gcc gcc-c++ make', { interactive: true });
|
||||
if (wantPkg) add('pkg-config', 'Install pkg-config + libopenssl-devel (zypper)', 'sudo zypper --non-interactive install pkg-config libopenssl-devel', { interactive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe by command to avoid repeating (keep order)
|
||||
const seen = new Set();
|
||||
return steps.filter(s => {
|
||||
const key = `${s.command}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function installPrereqs(installTokens, opts = {}) {
|
||||
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => { };
|
||||
const envBase = opts.env || process.env;
|
||||
const env = envWithCargoOnPath(envBase);
|
||||
|
||||
const steps = buildInstallSteps(installTokens);
|
||||
const results = [];
|
||||
|
||||
for (const step of steps) {
|
||||
onEvent({ type: 'step', state: 'start', step });
|
||||
|
||||
const stepEnv = step.envPatch === 'cargo' ? envWithCargoOnPath(env) : env;
|
||||
// Always stream so the TUI can show progress; interactive steps may require the user to respond (UAC/sudo).
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await execStreaming(step.command, { env: stepEnv }, (line) => onEvent({ type: 'data', step, line }));
|
||||
|
||||
const entry = { ...step, ...res };
|
||||
results.push(entry);
|
||||
onEvent({ type: 'step', state: 'end', step, result: entry });
|
||||
if (!res.success) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Backward-compatible exports (used by existing wiring)
|
||||
export function detectWindowsPrereqs() {
|
||||
return detectPrereqs();
|
||||
}
|
||||
|
||||
export function installWindowsPrereqs(plan, onLog = () => { }) {
|
||||
return installPrereqs(plan, {
|
||||
onEvent: (ev) => {
|
||||
if (ev.type === 'step' && ev.state === 'start') onLog(`==> ${ev.step.label}`);
|
||||
if (ev.type === 'data') onLog(ev.line);
|
||||
if (ev.type === 'step' && ev.state === 'end') onLog(ev.result?.success ? '✓ done' : `x failed (${ev.result?.code || 1})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
102
lib/todo-scanner.mjs
Normal file
102
lib/todo-scanner.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* TodoScanner - Auto-scan TODO comments from project files (Vibe Upgrade)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const TODO_PATTERNS = [
|
||||
/\/\/\s*TODO:?\s*(.+)/gi, // JS/TS: // TODO
|
||||
/#\s*TODO:?\s*(.+)/gi, // Python/Shell: # TODO
|
||||
/<!--\s*TODO:?\s*(.+?)-->/gi, // HTML/MD: <!-- TODO -->
|
||||
/\/\*+\s*TODO:?\s*(.+?)\*+\//gi // C-style: /* TODO */
|
||||
];
|
||||
|
||||
const SCAN_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.md', '.html', '.css', '.json'];
|
||||
|
||||
/**
|
||||
* Recursively scan directory for TODO comments
|
||||
* @param {string} rootPath - Root directory to scan
|
||||
* @param {number} maxFiles - Max files to scan (to prevent performance issues)
|
||||
* @returns {Array} - Array of { file, line, text }
|
||||
*/
|
||||
export async function scanTodos(rootPath, maxFiles = 100) {
|
||||
const todos = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
const scan = async (dir) => {
|
||||
if (filesScanned >= maxFiles) return;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (filesScanned >= maxFiles) break;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
// Skip common ignored directories
|
||||
if (entry.isDirectory()) {
|
||||
if (['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', 'vendor'].includes(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await scan(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (SCAN_EXTENSIONS.includes(ext)) {
|
||||
filesScanned++;
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
for (const pattern of TODO_PATTERNS) {
|
||||
// Reset lastIndex for global patterns
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(line);
|
||||
if (match && match[1]) {
|
||||
todos.push({
|
||||
file: path.relative(rootPath, fullPath),
|
||||
line: index + 1,
|
||||
text: match[1].trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip unreadable directories
|
||||
}
|
||||
};
|
||||
|
||||
await scan(rootPath);
|
||||
return todos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted TODO display for Sidebar
|
||||
* @param {Array} todos - Array of { file, line, text }
|
||||
* @param {number} limit - Max items to display
|
||||
* @returns {string} - Formatted display string
|
||||
*/
|
||||
export function formatTodoDisplay(todos, limit = 5) {
|
||||
if (!todos || todos.length === 0) {
|
||||
return '📝 No TODOs found';
|
||||
}
|
||||
|
||||
const display = todos.slice(0, limit).map(t => {
|
||||
const shortFile = t.file.length > 20 ? '...' + t.file.slice(-17) : t.file;
|
||||
const shortText = t.text.length > 30 ? t.text.slice(0, 27) + '...' : t.text;
|
||||
return `• ${shortFile}:${t.line}\n ${shortText}`;
|
||||
}).join('\n');
|
||||
|
||||
const remaining = todos.length > limit ? `\n... and ${todos.length - limit} more` : '';
|
||||
return display + remaining;
|
||||
}
|
||||
|
||||
export default { scanTodos, formatTodoDisplay };
|
||||
Reference in New Issue
Block a user