| import { VirtualFileSystem } from './index'; |
| import { drainCompileErrors, formatCompileErrors } from '@/lib/preview/compile-errors'; |
| import { track } from '@/lib/telemetry'; |
| import { isExternalCurl } from '@/lib/llm/permissions'; |
| import { base64ToArrayBuffer } from '@/lib/vfs/binary-encoding'; |
|
|
| |
| |
| |
| |
| |
| |
| export interface ShellContext { |
| onProgress?: (event: string, data?: any) => void; |
| |
| |
| generateImage?: (prompt: string, opts: { aspectRatio?: string; imageSize?: string }) => Promise<{ base64: string; mimeType: string }>; |
| } |
|
|
| type ShellResult = { |
| stdout: string; |
| stderr: string; |
| exitCode: number; |
| |
| |
| |
| |
| exitReason?: string; |
| }; |
|
|
| const TRUNCATE_CHARS = 100_000; |
|
|
| function truncate(out: string): string { |
| if (out.length <= TRUNCATE_CHARS) return out; |
| return out.slice(0, TRUNCATE_CHARS) + `\n\n… [${out.length - TRUNCATE_CHARS} chars truncated] …`; |
| } |
|
|
| function normalizePath(p?: string): string | undefined { |
| if (!p) return p; |
| if (p.startsWith('/workspace')) { |
| const rest = p.slice('/workspace'.length); |
| p = rest.length ? rest : '/'; |
| } |
| |
| |
| if (p === '.' || p === './') return '/'; |
| if (p.startsWith('./')) p = p.slice(2); |
| if (!p.startsWith('/')) p = '/' + p; |
| return p; |
| } |
|
|
| async function ensureDirectory(vfs: VirtualFileSystem, projectId: string, path: string) { |
| if (path === '/' || !path) return; |
| const parts = path.split('/').filter(Boolean); |
| let cur = ''; |
| for (let i = 0; i < parts.length; i++) { |
| cur = '/' + parts.slice(0, i + 1).join('/'); |
| try { |
| |
| await vfs.createDirectory(projectId, cur); |
| } catch { |
| |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function stripBashRedirects(args: string[]): string[] { |
| const result: string[] = []; |
| for (let i = 0; i < args.length; i++) { |
| const token = args[i]; |
| |
| if (token === '2>&1') continue; |
| |
| if (/^(?:2|1|&)>>?$/.test(token)) { i++; continue; } |
| |
| if (/^(?:2|1|&)>>?./.test(token)) continue; |
| result.push(token); |
| } |
| return result; |
| } |
|
|
| |
| |
| |
| |
| function extractRedirect(args: string[]): { cleanArgs: string[]; redirect?: { file: string; append: boolean } } { |
| const appendIdx = args.indexOf('>>'); |
| const overwriteIdx = args.indexOf('>'); |
|
|
| |
| let idx: number; |
| if (appendIdx !== -1 && overwriteIdx !== -1) { |
| idx = appendIdx <= overwriteIdx ? appendIdx : overwriteIdx; |
| } else { |
| idx = appendIdx !== -1 ? appendIdx : overwriteIdx; |
| } |
| if (idx === -1) return { cleanArgs: args }; |
|
|
| const append = args[idx] === '>>'; |
| const file = args[idx + 1]; |
| if (!file) return { cleanArgs: args }; |
|
|
| const cleanArgs = [...args.slice(0, idx), ...args.slice(idx + 2)]; |
| return { cleanArgs, redirect: { file, append } }; |
| } |
|
|
| |
| |
| |
| async function applyRedirect( |
| vfs: VirtualFileSystem, |
| projectId: string, |
| content: string, |
| redirect: { file: string; append: boolean } |
| ): Promise<ShellResult> { |
| const path = normalizePath(redirect.file); |
| if (!path) return { stdout: '', stderr: 'redirect: missing file path', exitCode: 2 }; |
|
|
| try { |
| const dirPath = path.split('/').slice(0, -1).join('/') || '/'; |
| if (dirPath !== '/') await ensureDirectory(vfs, projectId, dirPath); |
|
|
| if (redirect.append) { |
| |
| let existing = ''; |
| try { |
| const file = await vfs.readFile(projectId, path); |
| if (typeof file.content === 'string') existing = file.content; |
| } catch { } |
| const newContent = existing ? existing + '\n' + content : content; |
| try { await vfs.createFile(projectId, path, newContent); } |
| catch { await vfs.updateFile(projectId, path, newContent); } |
| } else { |
| |
| try { await vfs.createFile(projectId, path, content); } |
| catch { await vfs.updateFile(projectId, path, content); } |
| } |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } catch (e: any) { |
| return { stdout: '', stderr: `redirect: ${path}: ${e?.message || 'cannot write file'}`, exitCode: 1 }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function breToEre(pat: string): string { |
| let result = ''; |
| let escaped = false; |
| let inCharClass = false; |
| for (let i = 0; i < pat.length; i++) { |
| const ch = pat[i]; |
| if (escaped) { |
| if (inCharClass) { |
| |
| result += '\\' + ch; |
| } else { |
| |
| |
| |
| if ('(){}+?|'.includes(ch)) { |
| result += ch; |
| } else { |
| result += '\\' + ch; |
| } |
| } |
| escaped = false; |
| continue; |
| } |
| if (ch === '\\') { escaped = true; continue; } |
| |
| if (ch === '[' && !inCharClass) { |
| inCharClass = true; |
| result += ch; |
| continue; |
| } |
| if (ch === ']' && inCharClass) { |
| inCharClass = false; |
| result += ch; |
| continue; |
| } |
| |
| if (inCharClass) { |
| result += ch; |
| continue; |
| } |
| |
| if ('(){}+?|'.includes(ch)) { |
| result += '\\' + ch; |
| } else { |
| result += ch; |
| } |
| } |
| if (escaped) result += '\\'; |
| return result; |
| } |
|
|
| function parseSedExpression(expr: string): { pattern: RegExp; replacement: string } | { error: string } { |
| if (!expr.startsWith('s')) return { error: `sed: invalid expression: ${expr}` }; |
|
|
| const delim = expr[1]; |
| if (!delim || !/[\/|#@]/.test(delim)) { |
| return { error: `sed: invalid delimiter in expression: ${expr}` }; |
| } |
|
|
| |
| const parts: string[] = []; |
| let current = ''; |
| let escaped = false; |
| for (let i = 2; i < expr.length; i++) { |
| const ch = expr[i]; |
| if (escaped) { current += ch; escaped = false; continue; } |
| if (ch === '\\') { escaped = true; current += ch; continue; } |
| if (ch === delim) { parts.push(current); current = ''; continue; } |
| current += ch; |
| } |
| parts.push(current); |
|
|
| if (parts.length < 2) { |
| return { error: `sed: incomplete expression: ${expr}\n\nUsage: sed 's/pattern/replacement/[flags]'\n flags: g (global)` }; |
| } |
|
|
| const [patStr, replStr, flagStr] = parts; |
|
|
| |
| if (patStr.includes('\\n') || replStr.includes('\\n')) { |
| return { error: `sed: multiline patterns with \\n are not supported.\n\nFor multiline edits, use ss (supersed):\n ss /file << 'EOF'\n text to find\n =======\n replacement text\n EOF` }; |
| } |
|
|
| const globalFlag = (flagStr || '').includes('g'); |
|
|
| try { |
| |
| const erePattern = breToEre(patStr); |
| const pattern = new RegExp(erePattern, globalFlag ? 'g' : ''); |
| |
| let replacement = replStr.replace(new RegExp('\\\\' + delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), delim); |
| |
| |
| replacement = replacement |
| .replace(/\\\\/g, '\x00BSLASH\x00') |
| .replace(/\\&/g, '\x00AMP\x00') |
| .replace(/&/g, '$$&') |
| .replace(/\\([1-9])/g, '$$$1') |
| .replace(/\x00AMP\x00/g, '&') |
| .replace(/\x00BSLASH\x00/g, '\\'); |
| return { pattern, replacement }; |
| } catch (e: any) { |
| return { error: `sed: invalid regex "${patStr}": ${e?.message || 'parse error'}` }; |
| } |
| } |
|
|
| |
| type SedAddress = { type: 'line'; line: number } | { type: 'pattern'; pattern: RegExp } | { type: 'last' }; |
|
|
| |
| type SedCommand = |
| | { kind: 'substitute'; pattern: RegExp; replacement: string; start?: SedAddress; end?: SedAddress; negate?: boolean } |
| | { kind: 'delete'; start: SedAddress; end?: SedAddress; negate?: boolean } |
| | { kind: 'change'; start: SedAddress; end?: SedAddress; text: string; negate?: boolean } |
| | { kind: 'insert'; start: SedAddress; text: string; negate?: boolean } |
| | { kind: 'append'; start: SedAddress; text: string; negate?: boolean } |
| | { kind: 'print'; start: SedAddress; end?: SedAddress; negate?: boolean } |
| | { kind: 'group'; start: SedAddress; end?: SedAddress; commands: SedCommand[] }; |
|
|
| |
| |
| |
| |
| function parseSedAddress(expr: string): { addr: SedAddress; rest: string } | null { |
| if (!expr) return null; |
|
|
| |
| const lineMatch = expr.match(/^(\d+)(.*)/); |
| if (lineMatch) { |
| return { addr: { type: 'line', line: parseInt(lineMatch[1], 10) }, rest: lineMatch[2] }; |
| } |
|
|
| |
| if (expr[0] === '$') { |
| return { addr: { type: 'last' }, rest: expr.slice(1) }; |
| } |
|
|
| |
| if (expr[0] === '/' || expr[0] === '\\') { |
| const delim = expr[0] === '\\' ? expr[1] : '/'; |
| const start = expr[0] === '\\' ? 2 : 1; |
| let pattern = ''; |
| let escaped = false; |
| let i = start; |
| for (; i < expr.length; i++) { |
| if (escaped) { pattern += expr[i]; escaped = false; continue; } |
| if (expr[i] === '\\') { escaped = true; pattern += '\\'; continue; } |
| if (expr[i] === delim) { i++; break; } |
| pattern += expr[i]; |
| } |
| try { |
| return { addr: { type: 'pattern', pattern: new RegExp(breToEre(pattern)) }, rest: expr.slice(i) }; |
| } catch { |
| return null; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| function parseSedCommand(expr: string): SedCommand | { error: string } { |
| |
| if (expr.startsWith('s') && expr.length > 2 && /[\/|#@]/.test(expr[1])) { |
| const parsed = parseSedExpression(expr); |
| if ('error' in parsed) return parsed; |
| return { kind: 'substitute', ...parsed }; |
| } |
|
|
| |
| const addr1Result = parseSedAddress(expr); |
| if (!addr1Result) { |
| return { error: `sed: unrecognized command: ${expr}` }; |
| } |
|
|
| let addr2: SedAddress | undefined; |
| let remaining = addr1Result.rest; |
|
|
| |
| if (remaining.startsWith(',')) { |
| const addr2Result = parseSedAddress(remaining.slice(1)); |
| if (!addr2Result) { |
| return { error: `sed: invalid end address in: ${expr}` }; |
| } |
| addr2 = addr2Result.addr; |
| remaining = addr2Result.rest; |
| } |
|
|
| |
| remaining = remaining.trim(); |
|
|
| |
| let negate = false; |
| if (remaining.startsWith('!')) { |
| negate = true; |
| remaining = remaining.slice(1).trim(); |
| } |
|
|
| |
| if (remaining.startsWith('{')) { |
| const closeIdx = remaining.lastIndexOf('}'); |
| if (closeIdx < 0) return { error: `sed: unmatched { in: ${expr}` }; |
| const inner = remaining.slice(1, closeIdx).trim(); |
| const innerParts = inner.split(';').map(s => s.trim()).filter(Boolean); |
| const commands: SedCommand[] = []; |
| for (const part of innerParts) { |
| const parsed = parseSedCommand(part); |
| if ('error' in parsed) return parsed; |
| commands.push(parsed); |
| } |
| return { kind: 'group', start: addr1Result.addr, end: addr2, commands }; |
| } |
|
|
| const neg = negate ? { negate: true as const } : {}; |
| if (remaining === 'd') { |
| return { kind: 'delete', start: addr1Result.addr, end: addr2, ...neg }; |
| } |
| if (remaining === 'p') { |
| return { kind: 'print', start: addr1Result.addr, end: addr2, ...neg }; |
| } |
| if (remaining.startsWith('c\\') || remaining.startsWith('c ')) { |
| const text = remaining.slice(2).replace(/\\n/g, '\n'); |
| return { kind: 'change', start: addr1Result.addr, end: addr2, text, ...neg }; |
| } |
| |
| if (remaining.startsWith('i\\') || remaining.startsWith('i ')) { |
| const text = remaining.slice(2).replace(/\\n/g, '\n'); |
| return { kind: 'insert', start: addr1Result.addr, text, ...neg }; |
| } |
| |
| if (remaining.startsWith('a\\') || remaining.startsWith('a ')) { |
| const text = remaining.slice(2).replace(/\\n/g, '\n'); |
| return { kind: 'append', start: addr1Result.addr, text, ...neg }; |
| } |
| |
| if (remaining.startsWith('s') && remaining.length > 2 && /[\/|#@]/.test(remaining[1])) { |
| const parsed = parseSedExpression(remaining); |
| if ('error' in parsed) return parsed; |
| return { kind: 'substitute', ...parsed, start: addr1Result.addr, end: addr2, ...neg }; |
| } |
|
|
| return { error: `sed: unsupported command "${remaining}" in: ${expr}` }; |
| } |
|
|
| |
| function addressMatches(addr: SedAddress, lineNum: number, lineContent: string, totalLines: number): boolean { |
| switch (addr.type) { |
| case 'line': return lineNum === addr.line; |
| case 'last': return lineNum === totalLines; |
| case 'pattern': return addr.pattern.test(lineContent); |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| function ssFindSelectorMatch(content: string, selector: string): { index: number; normalizedSelector: string } | null { |
| const variants: string[] = []; |
| const seen = new Set<string>(); |
|
|
| const addVariant = (value: string) => { |
| if (!value || seen.has(value)) return; |
| seen.add(value); |
| variants.push(value); |
| }; |
|
|
| addVariant(selector); |
| addVariant(selector.replace(/^\s+/, '')); |
| addVariant(selector.replace(/\s+$/, '')); |
| addVariant(selector.replace(/^\s+/, '').replace(/\s+$/, '')); |
|
|
| for (const variant of variants) { |
| const index = content.indexOf(variant); |
| if (index !== -1) { |
| return { index, normalizedSelector: variant }; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| function ssIsHtmlEntity(selector: string): boolean { |
| return selector.startsWith('<') && selector.includes('>'); |
| } |
|
|
| |
| |
| |
| function ssDetectEntityBoundary( |
| content: string, |
| selectorIndex: number, |
| selector: string, |
| isHtml: boolean |
| ): { start: number; end: number } | null { |
| if (selectorIndex < 0 || selectorIndex >= content.length) return null; |
|
|
| if (isHtml) { |
| return ssDetectHtmlElementBoundary(content, selectorIndex, selector); |
| } |
| return ssDetectBracketBoundary(content, selectorIndex); |
| } |
|
|
| const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']); |
|
|
| |
| |
| |
| |
| function ssDetectHtmlElementBoundary( |
| content: string, |
| selectorIndex: number, |
| selector: string |
| ): { start: number; end: number } | null { |
| const tagMatch = selector.match(/<(\w+)(?:\s|>|\/)/); |
| if (!tagMatch) return null; |
|
|
| const tagName = tagMatch[1]; |
| const start = selectorIndex; |
|
|
| |
| if (selector.includes('/>') || VOID_ELEMENTS.has(tagName.toLowerCase())) { |
| |
| let tagEnd = selectorIndex; |
| let inQuote: string | null = null; |
| while (tagEnd < content.length) { |
| const ch = content[tagEnd]; |
| if (inQuote) { |
| if (ch === inQuote) inQuote = null; |
| } else if (ch === '"' || ch === "'") { |
| inQuote = ch; |
| } else if (ch === '>') { |
| return { start, end: tagEnd + 1 }; |
| } |
| tagEnd++; |
| } |
| return null; |
| } |
|
|
| |
| |
| const openRe = new RegExp(`<${tagName}(?:\\s(?:[^>"']*|"[^"]*"|'[^']*')*)?>`, 'gi'); |
| const closeRe = new RegExp(`</${tagName}>`, 'gi'); |
|
|
| |
| const events: { pos: number; len: number; type: 'open' | 'close' }[] = []; |
|
|
| openRe.lastIndex = selectorIndex; |
| let m: RegExpExecArray | null; |
| while ((m = openRe.exec(content)) !== null) { |
| |
| if (content[m.index + m[0].length - 2] === '/') continue; |
| events.push({ pos: m.index, len: m[0].length, type: 'open' }); |
| } |
|
|
| closeRe.lastIndex = selectorIndex; |
| while ((m = closeRe.exec(content)) !== null) { |
| events.push({ pos: m.index, len: m[0].length, type: 'close' }); |
| } |
|
|
| events.sort((a, b) => a.pos - b.pos); |
|
|
| let depth = 0; |
| for (const ev of events) { |
| if (ev.type === 'open') { |
| depth++; |
| } else { |
| if (depth > 0) depth--; |
| if (depth === 0) { |
| return { start, end: ev.pos + ev.len }; |
| } |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| function ssDetectBracketBoundary( |
| content: string, |
| selectorIndex: number |
| ): { start: number; end: number } | null { |
| |
| const openPos = content.indexOf('{', selectorIndex); |
| if (openPos === -1) return null; |
|
|
| const start = selectorIndex; |
| let depth = 0; |
| let i = openPos; |
|
|
| while (i < content.length) { |
| const ch = content[i]; |
|
|
| |
| if (ch === '/' && content[i + 1] === '/') { |
| const eol = content.indexOf('\n', i); |
| i = eol === -1 ? content.length : eol + 1; |
| continue; |
| } |
|
|
| |
| if (ch === '/' && content[i + 1] === '*') { |
| const endComment = content.indexOf('*/', i + 2); |
| i = endComment === -1 ? content.length : endComment + 2; |
| continue; |
| } |
|
|
| |
| if (ch === '"') { |
| i++; |
| while (i < content.length) { |
| if (content[i] === '\\') { i += 2; continue; } |
| if (content[i] === '"') { i++; break; } |
| i++; |
| } |
| continue; |
| } |
|
|
| |
| if (ch === "'") { |
| i++; |
| while (i < content.length) { |
| if (content[i] === '\\') { i += 2; continue; } |
| if (content[i] === "'") { i++; break; } |
| i++; |
| } |
| continue; |
| } |
|
|
| |
| if (ch === '`') { |
| i++; |
| while (i < content.length) { |
| if (content[i] === '\\') { i += 2; continue; } |
| if (content[i] === '`') { i++; break; } |
| |
| if (content[i] === '$' && content[i + 1] === '{') { |
| let tDepth = 1; |
| i += 2; |
| while (i < content.length && tDepth > 0) { |
| if (content[i] === '{') tDepth++; |
| else if (content[i] === '}') tDepth--; |
| i++; |
| } |
| continue; |
| } |
| i++; |
| } |
| continue; |
| } |
|
|
| if (ch === '{') { |
| depth++; |
| } else if (ch === '}') { |
| depth--; |
| if (depth === 0) { |
| return { start, end: i + 1 }; |
| } |
| } |
| i++; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| const WS_RE = /\s/; |
|
|
| function ssMapNormalizedToOriginal(content: string, normalizedSearch: string): { start: number; end: number } | null { |
| |
| |
| const contentLen = content.length; |
| const searchLen = normalizedSearch.length; |
|
|
| |
| for (let origStart = 0; origStart < contentLen; origStart++) { |
| let oi = origStart; |
| let si = 0; |
| let matched = true; |
|
|
| while (si < searchLen && oi < contentLen) { |
| |
| if (normalizedSearch[si] === ' ') { |
| |
| if (!WS_RE.test(content[oi])) { matched = false; break; } |
| |
| while (oi < contentLen && WS_RE.test(content[oi])) oi++; |
| si++; |
| } else { |
| if (content[oi] !== normalizedSearch[si]) { matched = false; break; } |
| oi++; |
| si++; |
| } |
| } |
|
|
| if (!matched) continue; |
| if (si === searchLen) { |
| return { start: origStart, end: oi }; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| async function vfsShellExecute( |
| vfs: VirtualFileSystem, |
| projectId: string, |
| cmd: string[], |
| stdin?: string, |
| ctx?: ShellContext |
| ): Promise<ShellResult> { |
| |
| if (!projectId || typeof projectId !== 'string') { |
| return { stdout: '', stderr: 'Invalid project ID provided', exitCode: 2 }; |
| } |
|
|
| if (!cmd || cmd.length === 0) { |
| return { stdout: '', stderr: 'No command provided', exitCode: 2 }; |
| } |
|
|
| const cleanCmd = stripBashRedirects( |
| cmd.filter(arg => arg !== undefined && arg !== null && arg !== '') |
| ); |
| if (cleanCmd.length === 0) { |
| return { stdout: '', stderr: 'No valid command arguments provided', exitCode: 2 }; |
| } |
|
|
| |
| if (cleanCmd.some(arg => arg === ';')) { |
| const commands: string[][] = []; |
| let currentCmd: string[] = []; |
|
|
| for (const arg of cleanCmd) { |
| if (arg === ';') { |
| if (currentCmd.length > 0) { |
| commands.push(currentCmd); |
| currentCmd = []; |
| } |
| } else { |
| currentCmd.push(arg); |
| } |
| } |
| if (currentCmd.length > 0) { |
| commands.push(currentCmd); |
| } |
|
|
| |
| const allStdout: string[] = []; |
| const allStderr: string[] = []; |
| let lastExitCode = 0; |
| let lastExitReason: string | undefined; |
|
|
| for (const singleCmd of commands) { |
| const result = await vfsShellExecuteSingle(vfs, projectId, singleCmd, undefined, ctx); |
| if (result.stdout) allStdout.push(result.stdout); |
| if (result.stderr) allStderr.push(result.stderr); |
| lastExitCode = result.exitCode; |
| lastExitReason = result.exitReason; |
| } |
|
|
| return { |
| stdout: allStdout.join('\n'), |
| stderr: allStderr.join('\n'), |
| exitCode: lastExitCode, |
| exitReason: lastExitReason |
| }; |
| } |
|
|
| |
| if (cleanCmd.some(arg => arg === '&&')) { |
| const commands: string[][] = []; |
| let currentCmd: string[] = []; |
|
|
| for (const arg of cleanCmd) { |
| if (arg === '&&') { |
| if (currentCmd.length > 0) { |
| commands.push(currentCmd); |
| currentCmd = []; |
| } |
| } else { |
| currentCmd.push(arg); |
| } |
| } |
| if (currentCmd.length > 0) { |
| commands.push(currentCmd); |
| } |
|
|
| |
| const allStdout: string[] = []; |
| const allStderr: string[] = []; |
| let lastExitReason: string | undefined; |
|
|
| for (const singleCmd of commands) { |
| const result = await vfsShellExecuteSingle(vfs, projectId, singleCmd, undefined, ctx); |
| if (result.stdout) allStdout.push(result.stdout); |
| if (result.stderr) allStderr.push(result.stderr); |
| lastExitReason = result.exitReason; |
|
|
| |
| if (result.exitCode !== 0) { |
| return { |
| stdout: allStdout.join('\n'), |
| stderr: allStderr.join('\n'), |
| exitCode: result.exitCode, |
| exitReason: result.exitReason |
| }; |
| } |
| } |
|
|
| return { |
| stdout: allStdout.join('\n'), |
| stderr: allStderr.join('\n'), |
| exitCode: 0, |
| exitReason: lastExitReason |
| }; |
| } |
|
|
| |
| if (cleanCmd.some(arg => arg === '||')) { |
| const commands: string[][] = []; |
| let currentCmd: string[] = []; |
|
|
| for (const arg of cleanCmd) { |
| if (arg === '||') { |
| if (currentCmd.length > 0) { |
| commands.push(currentCmd); |
| currentCmd = []; |
| } |
| } else { |
| currentCmd.push(arg); |
| } |
| } |
| if (currentCmd.length > 0) { |
| commands.push(currentCmd); |
| } |
|
|
| |
| let lastResult: ShellResult = { stdout: '', stderr: '', exitCode: 1 }; |
| for (const singleCmd of commands) { |
| lastResult = await vfsShellExecuteSingle(vfs, projectId, singleCmd, undefined, ctx); |
| if (lastResult.exitCode === 0) { |
| return lastResult; |
| } |
| } |
|
|
| return lastResult; |
| } |
|
|
| |
| if (cleanCmd.some(arg => arg === '|')) { |
| const segments: string[][] = []; |
| let currentSeg: string[] = []; |
|
|
| for (const arg of cleanCmd) { |
| if (arg === '|') { |
| if (currentSeg.length > 0) { |
| segments.push(currentSeg); |
| currentSeg = []; |
| } |
| } else { |
| currentSeg.push(arg); |
| } |
| } |
| if (currentSeg.length > 0) segments.push(currentSeg); |
|
|
| if (segments.length < 2) { |
| return vfsShellExecuteSingle(vfs, projectId, cleanCmd, undefined, ctx); |
| } |
|
|
| |
| let pipeStdin: string | undefined = stdin; |
| for (let i = 0; i < segments.length; i++) { |
| const result = await vfsShellExecuteSingle(vfs, projectId, segments[i], pipeStdin, ctx); |
| if (result.exitCode !== 0) return result; |
| pipeStdin = result.stdout; |
| } |
|
|
| return { stdout: pipeStdin || '', stderr: '', exitCode: 0 }; |
| } |
|
|
| return vfsShellExecuteSingle(vfs, projectId, cleanCmd, stdin, ctx); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function expandGlobs( |
| vfs: VirtualFileSystem, |
| projectId: string, |
| args: string[] |
| ): Promise<string[]> { |
| |
| if (!args.some(a => a && !a.startsWith('-') && (a.includes('*') || a.includes('?')))) { |
| return args; |
| } |
|
|
| |
| const allEntries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const allPaths = allEntries.map((e: any) => e.path as string); |
|
|
| const expanded: string[] = []; |
| for (const arg of args) { |
| if (!arg || arg.startsWith('-') || (!arg.includes('*') && !arg.includes('?'))) { |
| expanded.push(arg); |
| continue; |
| } |
|
|
| |
| const normalized = normalizePath(arg) || arg; |
|
|
| |
| const regexStr = normalized |
| .replace(/[.+^${}()|[\]\\]/g, '\\$&') |
| .replace(/\*/g, '[^/]*') |
| .replace(/\?/g, '[^/]'); |
|
|
| const regex = new RegExp(`^${regexStr}$`); |
| const matches = allPaths.filter(p => regex.test(p)).sort(); |
|
|
| if (matches.length > 0) { |
| expanded.push(...matches); |
| } else { |
| expanded.push(arg); |
| } |
| } |
|
|
| return expanded; |
| } |
|
|
| |
| |
| |
| const GLOB_EXPAND_COMMANDS = new Set([ |
| 'wc', 'ls', 'cat', 'rm', 'cp', 'mv', 'touch', |
| ]); |
|
|
| async function vfsShellExecuteSingle( |
| vfs: VirtualFileSystem, |
| projectId: string, |
| cleanCmd: string[], |
| stdin?: string, |
| ctx?: ShellContext |
| ): Promise<ShellResult> { |
| |
| const { cleanArgs: argsAfterRedirect, redirect } = extractRedirect(cleanCmd.slice(1)); |
| const program = cleanCmd[0]; |
| const args = GLOB_EXPAND_COMMANDS.has(program) |
| ? await expandGlobs(vfs, projectId, argsAfterRedirect) |
| : argsAfterRedirect; |
|
|
| try { |
| switch (program) { |
| case 'ls': { |
| |
| const lsFlags = new Set<string>(); |
| const lsPaths: string[] = []; |
| for (const a of args) { |
| if (a && a.startsWith('-')) lsFlags.add(a); |
| else if (a) lsPaths.push(a); |
| } |
| const recursive = lsFlags.has('-R') || lsFlags.has('-r'); |
| const longFormat = lsFlags.has('-l') || lsFlags.has('-la') || lsFlags.has('-al') || lsFlags.has('-lh') || lsFlags.has('-lha') || lsFlags.has('-lah'); |
| const humanReadable = lsFlags.has('-lh') || lsFlags.has('-lha') || lsFlags.has('-lah') || lsFlags.has('-h'); |
|
|
| const formatFileSize = (bytes: number): string => { |
| if (!humanReadable) return String(bytes).padStart(8); |
| if (bytes < 1024) return `${bytes}B`.padStart(8); |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`.padStart(8); |
| return `${(bytes / (1024 * 1024)).toFixed(1)}M`.padStart(8); |
| }; |
|
|
| const formatFileLong = (f: { path: string; size?: number; updatedAt?: Date }) => { |
| const size = formatFileSize(f.size || 0); |
| const date = f.updatedAt ? new Date(f.updatedAt).toISOString().slice(0, 16).replace('T', ' ') : ' '; |
| return `${size} ${date} ${f.path}`; |
| }; |
|
|
| |
| if (lsPaths.length > 1) { |
| const lines: string[] = []; |
| for (let pi = 0; pi < lsPaths.length; pi++) { |
| const np = normalizePath(lsPaths[pi]); |
| if (!np) continue; |
| |
| try { |
| const file = await vfs.readFile(projectId, np); |
| lines.push(longFormat ? formatFileLong(file) : file.path); |
| continue; |
| } catch { } |
| |
| const dirFiles = await vfs.listDirectory(projectId, np, { includeTransient: true }); |
| if (dirFiles.length > 0) { |
| if (pi > 0) lines.push(''); |
| lines.push(`${np}:`); |
| const sorted = dirFiles.sort((a, b) => a.path.localeCompare(b.path)); |
| for (const f of sorted) { |
| lines.push(longFormat ? formatFileLong(f) : f.path); |
| } |
| } else { |
| lines.push(`ls: ${np}: No such file or directory`); |
| } |
| } |
| const lsOutput = lines.join('\n'); |
| const lsResult: ShellResult = { stdout: truncate(lsOutput), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, lsResult.stdout, redirect); |
| return lsResult; |
| } |
|
|
| |
| const lsPath = normalizePath(lsPaths[0]) || '/'; |
| let lsOutput: string; |
| if (!recursive) { |
| const files = await vfs.listDirectory(projectId, lsPath, { includeTransient: true }); |
| const sorted = files.sort((a, b) => a.path.localeCompare(b.path)); |
| lsOutput = longFormat |
| ? sorted.map(f => formatFileLong(f)).join('\n') |
| : sorted.map(f => f.path).join('\n'); |
| } else { |
| const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const prefix = lsPath === '/' ? '/' : (lsPath.endsWith('/') ? lsPath : lsPath + '/'); |
| const filtered = entries |
| .filter((e: any) => e.path === lsPath || e.path.startsWith(prefix)) |
| .sort((a: any, b: any) => a.path.localeCompare(b.path)); |
| lsOutput = longFormat |
| ? filtered.map((e: any) => formatFileLong(e)).join('\n') |
| : filtered.map((e: any) => e.path).join('\n'); |
| } |
| const lsResult: ShellResult = { stdout: truncate(lsOutput), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, lsResult.stdout, redirect); |
| return lsResult; |
| } |
| case 'tree': { |
| |
| let maxDepth = Infinity; |
| let targetPath = '/'; |
|
|
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a === '-L' && args[i + 1]) { |
| maxDepth = parseInt(args[++i]) || Infinity; |
| } else if (!a.startsWith('-')) { |
| targetPath = a; |
| } |
| } |
|
|
| const basePath = normalizePath(targetPath) || '/'; |
| const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const prefix = basePath === '/' ? '' : basePath; |
|
|
| |
| const allPaths = new Set<string>(); |
| const dirPaths = new Set<string>(); |
|
|
| for (const entry of entries) { |
| const entryPath = entry.path; |
| |
| if (basePath !== '/' && !entryPath.startsWith(basePath + '/') && entryPath !== basePath) { |
| continue; |
| } |
|
|
| allPaths.add(entryPath); |
| const isDir = 'type' in entry && entry.type === 'directory'; |
| if (isDir) dirPaths.add(entryPath); |
|
|
| |
| const parts = entryPath.split('/').filter(Boolean); |
| let currentPath = ''; |
| for (let i = 0; i < parts.length - 1; i++) { |
| currentPath += '/' + parts[i]; |
| if (!allPaths.has(currentPath)) { |
| allPaths.add(currentPath); |
| dirPaths.add(currentPath); |
| } |
| } |
| } |
|
|
| |
| const sortedPaths = Array.from(allPaths) |
| .filter(p => { |
| if (basePath === '/') return p !== '/'; |
| return p.startsWith(basePath + '/') || p === basePath; |
| }) |
| .sort(); |
|
|
| |
| interface TreeNode { |
| name: string; |
| path: string; |
| isDir: boolean; |
| children: TreeNode[]; |
| } |
|
|
| |
| const root: TreeNode = { name: basePath === '/' ? '.' : basePath.split('/').pop() || '.', path: basePath, isDir: true, children: [] }; |
| const nodeMap = new Map<string, TreeNode>(); |
| nodeMap.set(basePath === '/' ? '' : basePath, root); |
|
|
| for (const p of sortedPaths) { |
| if (p === basePath) continue; |
| const relativePath = basePath === '/' ? p : p.slice(basePath.length); |
| const parts = relativePath.split('/').filter(Boolean); |
| const depth = parts.length; |
| if (depth > maxDepth) continue; |
|
|
| const name = parts[parts.length - 1]; |
| const parentPath = basePath === '/' |
| ? '/' + parts.slice(0, -1).join('/') |
| : basePath + '/' + parts.slice(0, -1).join('/'); |
| const normalizedParent = parentPath === '/' ? '' : parentPath.replace(/\/$/, ''); |
|
|
| const node: TreeNode = { |
| name, |
| path: p, |
| isDir: dirPaths.has(p), |
| children: [] |
| }; |
|
|
| const parent = nodeMap.get(normalizedParent) || root; |
| parent.children.push(node); |
| nodeMap.set(p, node); |
| } |
|
|
| |
| const lines: string[] = [basePath]; |
|
|
| function renderNode(node: TreeNode, prefix: string, isLast: boolean, isRoot: boolean): void { |
| if (!isRoot) { |
| const connector = isLast ? '└── ' : '├── '; |
| const suffix = node.isDir ? '/' : ''; |
| lines.push(prefix + connector + node.name + suffix); |
| } |
|
|
| const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ '); |
| node.children.sort((a, b) => { |
| |
| if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; |
| return a.name.localeCompare(b.name); |
| }); |
|
|
| for (let i = 0; i < node.children.length; i++) { |
| renderNode(node.children[i], childPrefix, i === node.children.length - 1, false); |
| } |
| } |
|
|
| renderNode(root, '', true, true); |
|
|
| const treeResult: ShellResult = { stdout: truncate(lines.join('\n')), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, treeResult.stdout, redirect); |
| return treeResult; |
| } |
| case 'cat': { |
| |
| const MAX_FILES = 5; |
| const filePaths = args.filter(a => a && !a.startsWith('-')).map(p => normalizePath(p)); |
|
|
| |
| if (filePaths.length === 0 && stdin !== undefined) { |
| const result: ShellResult = { stdout: truncate(stdin), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); |
| return result; |
| } |
|
|
| if (filePaths.length === 0) { |
| return { stdout: '', stderr: 'cat: missing file path', exitCode: 2 }; |
| } |
|
|
| if (filePaths.length > MAX_FILES) { |
| return { |
| stdout: '', |
| stderr: `cat: too many files. You requested ${filePaths.length} files, but cat supports a maximum of ${MAX_FILES} files at a time. Please split into multiple cat calls.`, |
| exitCode: 2 |
| }; |
| } |
|
|
| const outputs: string[] = []; |
| let hadError = false; |
| const errorMessages: string[] = []; |
|
|
| for (const path of filePaths) { |
| if (!path) { |
| errorMessages.push('cat: invalid path'); |
| hadError = true; |
| continue; |
| } |
|
|
| if (path.startsWith('/-')) { |
| errorMessages.push(`cat: invalid path "${path}" (looks like an option)`); |
| hadError = true; |
| continue; |
| } |
|
|
| if (path === '/<<' || path?.startsWith('/<<') || path === '<<' || path?.startsWith('<<')) { |
| errorMessages.push(`cat: heredoc syntax error — the << operator was not parsed correctly. Write each file in a separate tool call instead of chaining multiple heredocs.`); |
| hadError = true; |
| continue; |
| } |
|
|
| try { |
| const file = await vfs.readFile(projectId, path); |
| if (typeof file.content !== 'string') { |
| errorMessages.push(`cat: ${path}: binary or non-text file`); |
| hadError = true; |
| } else { |
| |
| if (filePaths.length > 1) { |
| outputs.push(`=== ${path} ===\n${file.content}`); |
| } else { |
| outputs.push(file.content); |
| } |
| } |
| } catch (error) { |
| const errMsg = error instanceof Error ? error.message : String(error); |
| errorMessages.push(`cat: ${path}: ${errMsg}`); |
| hadError = true; |
| } |
| } |
|
|
| const stdout = outputs.join('\n\n'); |
| const stderr = errorMessages.join('\n'); |
|
|
| const catResult: ShellResult = { stdout: truncate(stdout), stderr, exitCode: hadError ? 1 : 0 }; |
| if (redirect && !hadError) return applyRedirect(vfs, projectId, catResult.stdout, redirect); |
| return catResult; |
| } |
| case 'head': { |
| |
| let numLines = 10; |
| let filePath = ''; |
|
|
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a === '-n' && args[i + 1]) { |
| numLines = parseInt(args[++i]) || 10; |
| } else if (/^-\d+$/.test(a)) { |
| |
| numLines = parseInt(a.slice(1)) || 10; |
| } else if (!a.startsWith('-')) { |
| filePath = a; |
| } |
| } |
|
|
| |
| if (!filePath && stdin !== undefined) { |
| const lines = stdin.split(/\r?\n/); |
| const output = lines.slice(0, numLines).join('\n'); |
| const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); |
| return result; |
| } |
|
|
| const path = normalizePath(filePath); |
| if (!path) return { stdout: '', stderr: 'head: missing file path', exitCode: 2 }; |
|
|
| try { |
| const file = await vfs.readFile(projectId, path); |
| if (typeof file.content !== 'string') { |
| return { stdout: '', stderr: `head: ${path}: binary file`, exitCode: 1 }; |
| } |
|
|
| const lines = file.content.split(/\r?\n/); |
| const output = lines.slice(0, numLines).join('\n'); |
| const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); |
| return result; |
| } catch (e: any) { |
| return { stdout: '', stderr: `head: ${path}: ${e?.message || 'file not found'}`, exitCode: 1 }; |
| } |
| } |
| case 'tail': { |
| |
| let numLines = 10; |
| let filePath = ''; |
|
|
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a === '-n' && args[i + 1]) { |
| numLines = parseInt(args[++i]) || 10; |
| } else if (/^-\d+$/.test(a)) { |
| |
| numLines = parseInt(a.slice(1)) || 10; |
| } else if (!a.startsWith('-')) { |
| filePath = a; |
| } |
| } |
|
|
| |
| if (!filePath && stdin !== undefined) { |
| const lines = stdin.split(/\r?\n/); |
| const output = lines.slice(-numLines).join('\n'); |
| const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); |
| return result; |
| } |
|
|
| const path = normalizePath(filePath); |
| if (!path) return { stdout: '', stderr: 'tail: missing file path', exitCode: 2 }; |
|
|
| try { |
| const file = await vfs.readFile(projectId, path); |
| if (typeof file.content !== 'string') { |
| return { stdout: '', stderr: `tail: ${path}: binary file`, exitCode: 1 }; |
| } |
|
|
| const lines = file.content.split(/\r?\n/); |
| const output = lines.slice(-numLines).join('\n'); |
| const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); |
| return result; |
| } catch (e: any) { |
| return { stdout: '', stderr: `tail: ${path}: ${e?.message || 'file not found'}`, exitCode: 1 }; |
| } |
| } |
| case 'grep': { |
| |
| const flags: Record<string, any> = { n: false, i: false, o: false, F: false, C: 0, A: 0, B: 0 }; |
| const fargs: string[] = []; |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a.startsWith('-') && a.length > 1 && !/^-\d+$/.test(a)) { |
| const flagStr = a.slice(1); |
| for (let j = 0; j < flagStr.length; j++) { |
| const ch = flagStr[j]; |
| if (ch === 'n') flags.n = true; |
| else if (ch === 'i') flags.i = true; |
| else if (ch === 'o') flags.o = true; |
| else if (ch === 'F') flags.F = true; |
| else if (ch === 'P') {} |
| else if (ch === 'C') { flags.C = parseInt(args[++i]) || 2; break; } |
| else if (ch === 'A') { flags.A = parseInt(args[++i]) || 2; break; } |
| else if (ch === 'B') { flags.B = parseInt(args[++i]) || 2; break; } |
| } |
| } else { |
| fargs.push(a); |
| } |
| } |
| const pattern = fargs[0]; |
| const path = normalizePath(fargs[1]) || '/'; |
| if (!pattern) { |
| return { |
| stdout: '', |
| stderr: `grep: missing pattern |
| |
| Usage: grep [FLAGS] PATTERN [PATH] |
| |
| Supported flags: |
| -n Show line numbers |
| -i Case insensitive search |
| -o Print only the matched parts of each line (one per line) |
| -F Treat pattern as literal string (not regex) |
| -P Perl-compatible regex (accepted, JS regex used) |
| -A NUM Show NUM lines after each match |
| -B NUM Show NUM lines before each match |
| -C NUM Show NUM lines of context (before and after) |
| |
| Examples: |
| {"cmd": ["grep", "searchterm", "/path"]} |
| {"cmd": ["grep", "-n", "pattern", "/file.txt"]} |
| {"cmd": ["grep", "-i", "TODO", "/"]} |
| {"cmd": ["grep", "-o", "href=\"[^\"]*\"", "/index.html"]} |
| {"cmd": ["grep", "-F", "exact.string", "/src"]} |
| {"cmd": ["grep", "-A", "3", "pattern", "/file.txt"]} |
| {"cmd": ["grep", "-C", "5", "function", "/src"]} |
| |
| Note: grep always searches recursively. rg (ripgrep) is also available.`, |
| exitCode: 2 |
| }; |
| } |
|
|
| |
| let regex: RegExp; |
| if (flags.F) { |
| const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| regex = new RegExp(escaped, flags.i ? 'i' : ''); |
| } else { |
| regex = new RegExp(pattern, flags.i ? 'i' : ''); |
| } |
|
|
| const outLines: string[] = []; |
| const hasContext = flags.C > 0 || flags.A > 0 || flags.B > 0; |
| const globalRegex = flags.o ? new RegExp(regex.source, regex.flags + 'g') : null; |
|
|
| |
| if (!fargs[1] && stdin !== undefined) { |
| const stdinLines = stdin.split(/\r?\n/); |
| if (flags.o) { |
| for (let i = 0; i < stdinLines.length; i++) { |
| const matches = [...stdinLines[i].matchAll(globalRegex!)]; |
| for (const m of matches) { |
| outLines.push(flags.n ? `${i + 1}:${m[0]}` : m[0]); |
| } |
| } |
| } else if (hasContext) { |
| const matchedStdinLines = new Set<number>(); |
| for (let i = 0; i < stdinLines.length; i++) { |
| if (regex.test(stdinLines[i])) matchedStdinLines.add(i); |
| } |
| if (matchedStdinLines.size > 0) { |
| const contextStdinLines = new Set<number>(); |
| const beforeCtx = flags.C || flags.B; |
| const afterCtx = flags.C || flags.A; |
| for (const ln of matchedStdinLines) { |
| for (let j = Math.max(0, ln - beforeCtx); j <= Math.min(stdinLines.length - 1, ln + afterCtx); j++) { |
| contextStdinLines.add(j); |
| } |
| } |
| for (const ln of Array.from(contextStdinLines).sort((a, b) => a - b)) { |
| outLines.push(flags.n ? `${ln + 1}:${stdinLines[ln]}` : stdinLines[ln]); |
| } |
| } |
| } else { |
| for (let i = 0; i < stdinLines.length; i++) { |
| if (regex.test(stdinLines[i])) { |
| outLines.push(flags.n ? `${i + 1}:${stdinLines[i]}` : stdinLines[i]); |
| } |
| } |
| } |
| } else { |
| const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const dirPrefix = path === '/' ? '/' : (path.endsWith('/') ? path : path + '/'); |
| for (const e of entries) { |
| if ('type' in e && e.type === 'directory') continue; |
| const file = e as any; |
| if (!file.path.startsWith(dirPrefix) && file.path !== path) continue; |
| if (typeof file.content !== 'string') continue; |
| const lines = file.content.split(/\r?\n/); |
|
|
| if (flags.o) { |
| for (let i = 0; i < lines.length; i++) { |
| const matches = [...lines[i].matchAll(globalRegex!)]; |
| for (const m of matches) { |
| outLines.push(`${file.path}${flags.n ? ':' + (i + 1) : ''}:${m[0]}`); |
| } |
| } |
| } else if (hasContext) { |
| const matchedLines = new Set<number>(); |
| for (let i = 0; i < lines.length; i++) { |
| if (regex.test(lines[i])) matchedLines.add(i); |
| } |
| if (matchedLines.size === 0) continue; |
|
|
| const contextLines = new Set<number>(); |
| const beforeContext = flags.C || flags.B; |
| const afterContext = flags.C || flags.A; |
| for (const lineNum of matchedLines) { |
| for (let j = Math.max(0, lineNum - beforeContext); j <= Math.min(lines.length - 1, lineNum + afterContext); j++) { |
| contextLines.add(j); |
| } |
| } |
|
|
| const sortedLines = Array.from(contextLines).sort((a, b) => a - b); |
| if (outLines.length > 0) outLines.push(''); |
| for (const lineNum of sortedLines) { |
| outLines.push(`${file.path}${flags.n ? ':' + (lineNum + 1) : ''}:${lines[lineNum]}`); |
| } |
| } else { |
| for (let i = 0; i < lines.length; i++) { |
| if (regex.test(lines[i])) { |
| outLines.push(`${file.path}${flags.n ? ':' + (i + 1) : ''}:${lines[i]}`); |
| } |
| } |
| } |
| } |
| } |
|
|
| const output = outLines.join('\n'); |
| if (outLines.length === 0) { |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } |
| const grepResult: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, grepResult.stdout, redirect); |
| return grepResult; |
| } |
| case 'rg': { |
| |
| |
| const flags: Record<string, any> = { n: true, i: false, C: 0, A: 0, B: 0 }; |
| const fargs: string[] = []; |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a.startsWith('-') && a.length > 1 && !/^-\d+$/.test(a)) { |
| |
| const flagStr = a.slice(1); |
| for (let j = 0; j < flagStr.length; j++) { |
| const ch = flagStr[j]; |
| if (ch === 'n') flags.n = true; |
| else if (ch === 'i') flags.i = true; |
| else if (ch === 'C') { flags.C = parseInt(args[++i]) || 2; break; } |
| else if (ch === 'A') { flags.A = parseInt(args[++i]) || 2; break; } |
| else if (ch === 'B') { flags.B = parseInt(args[++i]) || 2; break; } |
| } |
| } else { |
| fargs.push(a); |
| } |
| } |
| const pattern = fargs[0]; |
| const path = normalizePath(fargs[1]) || '/'; |
| if (!pattern) { |
| return { |
| stdout: '', |
| stderr: `rg: missing pattern |
| |
| Usage: rg [FLAGS] PATTERN [PATH] |
| |
| Supported flags: |
| -C NUM Show NUM lines of context (before and after) |
| -A NUM Show NUM lines after each match |
| -B NUM Show NUM lines before each match |
| -i Case insensitive search |
| -n Show line numbers (enabled by default) |
| |
| Examples: |
| {"cmd": ["rg", "searchterm", "/"]} |
| {"cmd": ["rg", "-C", "3", "pattern", "/"]} |
| {"cmd": ["rg", "-A", "5", "-B", "2", "function", "/src"]} |
| {"cmd": ["rg", "-i", "todo", "/"]} |
| |
| Tip: Use -C for balanced context. PATH defaults to / if omitted.`, |
| exitCode: 2 |
| }; |
| } |
|
|
| const regex = new RegExp(pattern, flags.i ? 'i' : ''); |
| const outLines: string[] = []; |
|
|
| |
| if (!fargs[1] && stdin !== undefined) { |
| const stdinLines = stdin.split(/\r?\n/); |
| const matchedStdinLines = new Set<number>(); |
| for (let i = 0; i < stdinLines.length; i++) { |
| if (regex.test(stdinLines[i])) matchedStdinLines.add(i); |
| } |
| if (matchedStdinLines.size > 0) { |
| const contextStdinLines = new Set<number>(); |
| const beforeCtx = flags.C || flags.B; |
| const afterCtx = flags.C || flags.A; |
| for (const ln of matchedStdinLines) { |
| for (let j = Math.max(0, ln - beforeCtx); j <= Math.min(stdinLines.length - 1, ln + afterCtx); j++) { |
| contextStdinLines.add(j); |
| } |
| } |
| for (const ln of Array.from(contextStdinLines).sort((a, b) => a - b)) { |
| const lineNumStr = flags.n ? `${ln + 1}:` : ''; |
| outLines.push(`${lineNumStr}${stdinLines[ln]}`); |
| } |
| } |
| } else { |
| const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const dirPrefix = path === '/' ? '/' : (path.endsWith('/') ? path : path + '/'); |
|
|
| for (const e of entries) { |
| if ('type' in e && e.type === 'directory') continue; |
| const file = e as any; |
| if (!file.path.startsWith(dirPrefix) && file.path !== path) continue; |
| if (typeof file.content !== 'string') continue; |
|
|
| const lines = file.content.split(/\r?\n/); |
| const matchedLines = new Set<number>(); |
|
|
| |
| for (let i = 0; i < lines.length; i++) { |
| if (regex.test(lines[i])) { |
| matchedLines.add(i); |
| } |
| } |
|
|
| if (matchedLines.size === 0) continue; |
|
|
| |
| const contextLines = new Set<number>(); |
| const beforeContext = flags.C || flags.B; |
| const afterContext = flags.C || flags.A; |
|
|
| for (const lineNum of matchedLines) { |
| for (let j = Math.max(0, lineNum - beforeContext); j <= Math.min(lines.length - 1, lineNum + afterContext); j++) { |
| contextLines.add(j); |
| } |
| } |
|
|
| |
| const sortedLines = Array.from(contextLines).sort((a, b) => a - b); |
| if (outLines.length > 0) outLines.push(''); |
|
|
| for (const lineNum of sortedLines) { |
| const lineNumStr = flags.n ? `${lineNum + 1}:` : ''; |
| outLines.push(`${file.path}:${lineNumStr}${lines[lineNum]}`); |
| } |
| } |
| } |
|
|
| if (outLines.length === 0) { |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } |
| const rgResult: ShellResult = { stdout: truncate(outLines.join('\n')), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, rgResult.stdout, redirect); |
| return rgResult; |
| } |
| case 'find': { |
| |
| let rootArg: string | undefined; |
| let pattern: string | undefined; |
| let typeFilter: 'f' | 'd' | undefined; |
| let maxDepth = Infinity; |
|
|
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (!a) continue; |
| if (a === '-name') { pattern = args[i + 1]; i++; continue; } |
| if (a === '-type') { |
| const typeVal = args[i + 1]; |
| if (typeVal === 'f' || typeVal === 'd') { |
| typeFilter = typeVal; |
| } |
| i++; |
| continue; |
| } |
| if (a === '-maxdepth') { maxDepth = parseInt(args[i + 1]) || 0; i++; continue; } |
| if (!a.startsWith('-') && !rootArg) rootArg = a; |
| } |
|
|
| const root = normalizePath(rootArg) || '/'; |
| const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const prefix = root === '/' ? '/' : (root.endsWith('/') ? root : root + '/'); |
| const toGlob = (s: string) => new RegExp('^' + s.replace(/[.+^${}()|\[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'); |
| const regex = pattern ? toGlob(pattern) : null; |
|
|
| |
| const rootDepth = root === '/' ? 0 : root.split('/').filter(Boolean).length; |
|
|
| const res = entries |
| .filter((e: any) => e.path === root || e.path.startsWith(prefix)) |
| .filter((e: any) => { |
| |
| const entryDepth = e.path === '/' ? 0 : e.path.split('/').filter(Boolean).length; |
| if (entryDepth - rootDepth > maxDepth) return false; |
| |
| if (typeFilter === 'f') { |
| return !('type' in e) || e.type !== 'directory'; |
| } |
| if (typeFilter === 'd') { |
| return 'type' in e && e.type === 'directory'; |
| } |
| return true; |
| }) |
| .map((e: any) => e.path) |
| .filter(p => (regex ? regex.test(p.split('/').pop() || p) : true)) |
| .sort(); |
|
|
| const findResult: ShellResult = { stdout: truncate(res.join('\n')), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, findResult.stdout, redirect); |
| return findResult; |
| } |
| case 'mkdir': { |
| |
| const hasP = args.includes('-p'); |
| const paths = args.filter(a => a && a !== '-p').map(p => normalizePath(p)); |
|
|
| if (paths.length === 0) { |
| return { stdout: '', stderr: 'mkdir: missing operand', exitCode: 2 }; |
| } |
|
|
| let hadError = false; |
| const errors: string[] = []; |
|
|
| for (const path of paths) { |
| if (!path) continue; |
|
|
| |
| if (path.startsWith('/.server/')) { |
| errors.push(`mkdir: cannot create '${path}': server context directories are auto-generated`); |
| hadError = true; |
| continue; |
| } |
|
|
| try { |
| if (hasP) { |
| await ensureDirectory(vfs, projectId, path); |
| } else { |
| await vfs.createDirectory(projectId, path); |
| } |
| } catch (e: any) { |
| hadError = true; |
| errors.push(`mkdir: cannot create directory '${path}': ${e?.message || 'unknown error'}`); |
| } |
| } |
|
|
| return { |
| stdout: '', |
| stderr: errors.join('\n'), |
| exitCode: hadError ? 1 : 0 |
| }; |
| } |
| case 'touch': { |
| |
| const paths = args.filter(a => a && !a.startsWith('-')).map(p => normalizePath(p)); |
|
|
| if (paths.length === 0) { |
| return { stdout: '', stderr: 'touch: missing file operand', exitCode: 2 }; |
| } |
|
|
| let hadError = false; |
| const errors: string[] = []; |
|
|
| for (const path of paths) { |
| if (!path) continue; |
|
|
| try { |
| |
| await vfs.readFile(projectId, path); |
| |
| } catch { |
| |
| try { |
| await vfs.createFile(projectId, path, ''); |
| } catch (e: any) { |
| hadError = true; |
| errors.push(`touch: cannot touch '${path}': ${e?.message || 'cannot create file'}`); |
| } |
| } |
| } |
|
|
| return { |
| stdout: '', |
| stderr: errors.join('\n'), |
| exitCode: hadError ? 1 : 0 |
| }; |
| } |
| case 'rm': { |
| |
| |
| let recursive = false; |
| let force = false; |
| let verbose = false; |
| const targets: string[] = []; |
|
|
| for (const arg of args) { |
| if (arg && arg.startsWith('-')) { |
| |
| if (arg.includes('r') || arg.includes('R')) recursive = true; |
| if (arg.includes('f')) force = true; |
| if (arg.includes('v')) verbose = true; |
| } else if (arg) { |
| targets.push(arg); |
| } |
| } |
|
|
| if (targets.length === 0) return { stdout: '', stderr: 'rm: missing operand', exitCode: 2 }; |
|
|
| let hadError = false; |
| const verboseOutput: string[] = []; |
| const errorMessages: string[] = []; |
|
|
| for (const target of targets) { |
| const path = normalizePath(target); |
| if (!path) { |
| if (!force) hadError = true; |
| continue; |
| } |
|
|
| |
| if (path.startsWith('/.server/')) { |
| try { |
| await vfs.deleteServerContextFile(path); |
| if (verbose) verboseOutput.push(`removed '${path}'`); |
| } catch (e: any) { |
| if (!force) { |
| hadError = true; |
| const msg = `rm: cannot remove '${path}': ${e?.message || 'unknown error'}`; |
| errorMessages.push(msg); |
| if (verbose) verboseOutput.push(msg); |
| } |
| } |
| continue; |
| } |
|
|
| try { |
| |
| await vfs.deleteFile(projectId, path); |
| if (verbose) verboseOutput.push(`removed '${path}'`); |
| } catch { |
| |
| if (recursive) { |
| try { |
| await vfs.deleteDirectory(projectId, path); |
| if (verbose) verboseOutput.push(`removed directory '${path}'`); |
| } catch { |
| if (!force) { |
| hadError = true; |
| const msg = `rm: cannot remove '${path}': No such file or directory`; |
| errorMessages.push(msg); |
| if (verbose) verboseOutput.push(msg); |
| } |
| } |
| } else { |
| if (!force) { |
| hadError = true; |
| const msg = `rm: cannot remove '${path}': Is a directory (use -r to remove directories)`; |
| errorMessages.push(msg); |
| if (verbose) verboseOutput.push(msg); |
| } |
| } |
| } |
| } |
|
|
| const stdout = verbose ? verboseOutput.join('\n') : ''; |
| const stderr = hadError ? errorMessages.join('\n') : ''; |
| return { stdout: truncate(stdout), stderr, exitCode: hadError ? 1 : 0 }; |
| } |
| case 'mv': { |
| const [rold, rnew] = args; |
| const oldPath = normalizePath(rold); |
| const newPath = normalizePath(rnew); |
| if (!oldPath || !newPath) return { stdout: '', stderr: 'mv: missing operands', exitCode: 2 }; |
| |
| try { |
| await vfs.renameFile(projectId, oldPath, newPath); |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } catch { |
| |
| await vfs.renameDirectory(projectId, oldPath, newPath); |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } |
| } |
| case 'cp': { |
| |
| const recursive = args.includes('-r'); |
| const filtered = args.filter(a => a !== '-r'); |
| let [src, dst] = filtered; |
| src = normalizePath(src) as string; |
| dst = normalizePath(dst) as string; |
| if (!src || !dst) return { stdout: '', stderr: 'cp: missing operands', exitCode: 2 }; |
| |
| try { |
| const file = await vfs.readFile(projectId, src); |
| try { |
| await vfs.createFile(projectId, dst, file.content as any); |
| } catch { |
| await vfs.updateFile(projectId, dst, file.content as any); |
| } |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } catch { |
| if (!recursive) { |
| return { stdout: '', stderr: 'cp: -r required for directories', exitCode: 1 }; |
| } |
| |
| const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); |
| const srcPrefix = src.endsWith('/') ? src : src + '/'; |
| for (const e2 of entries) { |
| if ('type' in e2 && e2.type === 'directory') continue; |
| const file = e2 as any; |
| if (file.path === src || file.path.startsWith(srcPrefix)) { |
| const rel = file.path.slice(src.length); |
| const target = (dst.endsWith('/') ? dst.slice(0, -1) : dst) + rel; |
| await ensureDirectory(vfs, projectId, target.split('/').slice(0, -1).join('/')); |
| try { |
| await vfs.createFile(projectId, target, file.content as any); |
| } catch { |
| await vfs.updateFile(projectId, target, file.content as any); |
| } |
| } |
| } |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } |
| } |
| case 'echo': { |
| |
| let suppressNewline = false; |
| let interpretEscapes = false; |
| let startIdx = 0; |
|
|
| |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a.startsWith('-') && a.length > 1 && /^-[ne]+$/.test(a)) { |
| for (const ch of a.slice(1)) { |
| if (ch === 'n') suppressNewline = true; |
| else if (ch === 'e') interpretEscapes = true; |
| } |
| startIdx = i + 1; |
| } else { |
| break; |
| } |
| } |
|
|
| let output = args.slice(startIdx).join(' '); |
|
|
| if (interpretEscapes) { |
| output = output |
| .replace(/\\n/g, '\n') |
| .replace(/\\t/g, '\t') |
| .replace(/\\\\/g, '\\'); |
| } |
|
|
| |
| |
|
|
| if (redirect) return applyRedirect(vfs, projectId, output, redirect); |
| return { stdout: truncate(output), stderr: '', exitCode: 0 }; |
| } |
| case 'sed': { |
| |
| |
| let inPlace = false; |
| let suppressOutput = false; |
| const expressions: string[] = []; |
| let filePath = ''; |
|
|
| |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| |
| |
| if (a === '-i' || (a.startsWith('-i') && a.length > 2 && !/^-i[a-z]$/i.test(a))) { inPlace = true; continue; } |
| if (a === '-n') { suppressOutput = true; continue; } |
| if (a === '-e' && args[i + 1]) { expressions.push(args[++i]); continue; } |
| |
| if (a.startsWith('s') && a.length > 2 && /[\/|#@]/.test(a[1])) { |
| expressions.push(a); |
| continue; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| if ( |
| /^\d+!?[,dpcians{]/.test(a) || |
| /^[\\$]/.test(a) || |
| /^\/[^/]*\/(?:!?[dpn]$|!?[cai]\\|!?\{|,)/.test(a) |
| ) { |
| expressions.push(a); |
| continue; |
| } |
| if (!a.startsWith('-') && a) filePath = a; |
| } |
|
|
| if (expressions.length === 0) { |
| return { |
| stdout: '', |
| stderr: `sed: missing expression |
| |
| Usage: sed [-i] [-n] [-e expr] 'expr' [file] |
| |
| Commands: |
| s/pattern/replacement/[g] Substitute (BRE: parens are literal) |
| /pattern1/,/pattern2/d Delete lines in range |
| /pattern1/,/pattern2/c\\text Replace range with text |
| /pattern/i\\text Insert text before matching line |
| /pattern/a\\text Append text after matching line |
| -n '/pattern/p' Print matching lines only |
| |
| Examples: |
| sed -i 's/old/new/g' /file.txt |
| sed -i '/<nav>/,/<\\/nav>/d' /file.txt |
| sed -i '/<nav>/,/<\\/nav>/c\\<nav>new</nav>' /file.txt |
| sed -i '/<\\/body>/i\\<footer>My footer</footer>' /file.txt |
| sed -n '/<script>/,/<\\/script>/p' /file.txt`, |
| exitCode: 2 |
| }; |
| } |
|
|
| |
| const parsedCmds: SedCommand[] = []; |
| for (const expr of expressions) { |
| const parsed = parseSedCommand(expr); |
| if ('error' in parsed) return { stdout: '', stderr: parsed.error, exitCode: 2 }; |
| parsedCmds.push(parsed); |
| } |
|
|
| |
| let inputContent: string; |
| const sedPath = normalizePath(filePath); |
|
|
| if (sedPath) { |
| try { |
| const file = await vfs.readFile(projectId, sedPath); |
| if (typeof file.content !== 'string') { |
| return { stdout: '', stderr: `sed: ${sedPath}: binary file`, exitCode: 1 }; |
| } |
| inputContent = file.content; |
| } catch (e: any) { |
| return { stdout: '', stderr: `sed: ${sedPath}: ${e?.message || 'file not found'}`, exitCode: 1 }; |
| } |
| } else if (stdin !== undefined) { |
| inputContent = stdin; |
| } else { |
| return { stdout: '', stderr: 'sed: no input file or stdin', exitCode: 2 }; |
| } |
|
|
| |
| const lines = inputContent.split(/\r?\n/); |
| const totalLines = lines.length; |
| const outputLines: string[] = []; |
| let substitutionCount = 0; |
|
|
| |
| const inRange = new Array(parsedCmds.length).fill(false); |
|
|
| for (let lineIdx = 0; lineIdx < totalLines; lineIdx++) { |
| let line = lines[lineIdx]; |
| const lineNum = lineIdx + 1; |
| let deleted = false; |
| let printed = false; |
| const appendAfter: string[] = []; |
|
|
| for (let ci = 0; ci < parsedCmds.length; ci++) { |
| const cmd = parsedCmds[ci]; |
|
|
| if (cmd.kind === 'substitute') { |
| let before: string; |
| |
| if (cmd.start) { |
| if (cmd.end) { |
| |
| if (!inRange[ci] && addressMatches(cmd.start, lineNum, line, totalLines)) { |
| inRange[ci] = true; |
| } |
| if (inRange[ci]) { |
| |
| const endMatch = addressMatches(cmd.end, lineNum, line, totalLines); |
| before = line; |
| line = line.replace(cmd.pattern, cmd.replacement); |
| if (line !== before) substitutionCount++; |
| if (endMatch) { |
| inRange[ci] = false; |
| } |
| } |
| } else { |
| |
| if (addressMatches(cmd.start, lineNum, line, totalLines)) { |
| before = line; |
| line = line.replace(cmd.pattern, cmd.replacement); |
| if (line !== before) substitutionCount++; |
| } |
| } |
| } else { |
| before = line; |
| line = line.replace(cmd.pattern, cmd.replacement); |
| if (line !== before) substitutionCount++; |
| } |
| continue; |
| } |
|
|
| |
| const startMatch = addressMatches(cmd.start, lineNum, line, totalLines); |
|
|
| |
| if (cmd.kind === 'insert') { |
| const apply = cmd.negate ? !startMatch : startMatch; |
| if (apply) outputLines.push(cmd.text); |
| continue; |
| } |
| if (cmd.kind === 'append') { |
| const apply = cmd.negate ? !startMatch : startMatch; |
| if (apply) appendAfter.push(cmd.text); |
| continue; |
| } |
|
|
| |
| if (cmd.kind === 'group') { |
| if ('end' in cmd && cmd.end) { |
| if (!inRange[ci] && startMatch) inRange[ci] = true; |
| if (inRange[ci]) { |
| const endMatch = addressMatches(cmd.end, lineNum, line, totalLines); |
| for (const sub of cmd.commands) { |
| const subAddr = sub.kind === 'substitute' ? sub.start : ('start' in sub ? sub.start : undefined); |
| let subMatch = subAddr ? addressMatches(subAddr, lineNum, line, totalLines) : true; |
| if ('negate' in sub && sub.negate) subMatch = !subMatch; |
| if (subMatch) { |
| if (sub.kind === 'delete') deleted = true; |
| else if (sub.kind === 'print') printed = true; |
| else if (sub.kind === 'substitute') { |
| const before = line; |
| line = line.replace(sub.pattern, sub.replacement); |
| if (line !== before) substitutionCount++; |
| } |
| } |
| } |
| if (endMatch) inRange[ci] = false; |
| } |
| } else { |
| if (startMatch) { |
| for (const sub of cmd.commands) { |
| const subAddr = sub.kind === 'substitute' ? sub.start : ('start' in sub ? sub.start : undefined); |
| let subMatch = subAddr ? addressMatches(subAddr, lineNum, line, totalLines) : true; |
| if ('negate' in sub && sub.negate) subMatch = !subMatch; |
| if (subMatch) { |
| if (sub.kind === 'delete') deleted = true; |
| else if (sub.kind === 'print') printed = true; |
| else if (sub.kind === 'substitute') { |
| const before = line; |
| line = line.replace(sub.pattern, sub.replacement); |
| if (line !== before) substitutionCount++; |
| } |
| } |
| } |
| } |
| } |
| continue; |
| } |
|
|
| if ('end' in cmd && cmd.end) { |
| |
| if (!inRange[ci]) { |
| if (startMatch) inRange[ci] = true; |
| } |
|
|
| if (inRange[ci]) { |
| const endMatch = addressMatches(cmd.end, lineNum, line, totalLines); |
|
|
| if (!cmd.negate) { |
| if (cmd.kind === 'delete') { |
| deleted = true; |
| } else if (cmd.kind === 'print') { |
| printed = true; |
| } else if (cmd.kind === 'change') { |
| deleted = true; |
| } |
| } |
|
|
| if (endMatch) { |
| if (!cmd.negate && cmd.kind === 'change') { |
| outputLines.push(cmd.text); |
| } |
| inRange[ci] = false; |
| } |
| } else if (cmd.negate) { |
| if (cmd.kind === 'delete') { |
| deleted = true; |
| } else if (cmd.kind === 'print') { |
| printed = true; |
| } |
| } |
| } else { |
| |
| const apply = cmd.negate ? !startMatch : startMatch; |
| if (apply) { |
| if (cmd.kind === 'delete') { |
| deleted = true; |
| } else if (cmd.kind === 'print') { |
| printed = true; |
| } else if (cmd.kind === 'change') { |
| deleted = true; |
| outputLines.push(cmd.text); |
| } |
| } |
| } |
| } |
|
|
| if (!deleted) { |
| if (suppressOutput) { |
| |
| if (printed) outputLines.push(line); |
| } else { |
| outputLines.push(line); |
| } |
| } |
| |
| for (const text of appendAfter) { |
| outputLines.push(text); |
| } |
| } |
|
|
| const outputContent = outputLines.join('\n'); |
|
|
| if (inPlace) { |
| if (!sedPath) { |
| return { stdout: '', stderr: 'sed: -i requires a file argument (cannot edit stdin in-place)', exitCode: 2 }; |
| } |
| try { |
| const hasSubstitutions = parsedCmds.some(c => c.kind === 'substitute'); |
| if (hasSubstitutions && substitutionCount === 0) { |
| return { stdout: `(0 substitutions — pattern did not match any line in ${sedPath})`, stderr: '', exitCode: 0 }; |
| } |
| await vfs.updateFile(projectId, sedPath, outputContent); |
| const note = hasSubstitutions ? ` (${substitutionCount} substitution${substitutionCount !== 1 ? 's' : ''})` : ''; |
| return { stdout: note, stderr: '', exitCode: 0 }; |
| } catch (e: any) { |
| return { stdout: '', stderr: `sed: ${sedPath}: ${e?.message || 'cannot write file'}`, exitCode: 1 }; |
| } |
| } |
|
|
| |
| if (redirect) return applyRedirect(vfs, projectId, outputContent, redirect); |
| return { stdout: truncate(outputContent), stderr: '', exitCode: 0 }; |
| } |
| case 'wc': { |
| |
| |
| |
| const wcFlags = { l: false, w: false, c: false }; |
| const wcFilePaths: string[] = []; |
| let wcAnyFlag = false; |
|
|
| for (const a of args) { |
| if (a && a.startsWith('-')) { |
| for (const ch of a.slice(1)) { |
| if (ch === 'l') { wcFlags.l = true; wcAnyFlag = true; } |
| else if (ch === 'w') { wcFlags.w = true; wcAnyFlag = true; } |
| else if (ch === 'c') { wcFlags.c = true; wcAnyFlag = true; } |
| } |
| } else if (a) { |
| wcFilePaths.push(a); |
| } |
| } |
|
|
| if (!wcAnyFlag) { wcFlags.l = true; wcFlags.w = true; wcFlags.c = true; } |
|
|
| const wcCount = (content: string) => ({ |
| l: content === '' ? 0 : (content.match(/\r?\n/g) || []).length, |
| w: content.trim() === '' ? 0 : content.trim().split(/\s+/).length, |
| c: content.length, |
| }); |
|
|
| const wcFormatLine = (counts: { l: number; w: number; c: number }, label?: string) => { |
| const parts: string[] = []; |
| if (wcFlags.l) parts.push(String(counts.l)); |
| if (wcFlags.w) parts.push(String(counts.w)); |
| if (wcFlags.c) parts.push(String(counts.c)); |
| if (label) parts.push(label); |
| return parts.join(' '); |
| }; |
|
|
| |
| if (wcFilePaths.length === 0) { |
| if (stdin === undefined) { |
| return { stdout: '', stderr: 'wc: no input file or stdin', exitCode: 2 }; |
| } |
| const wcOutput = wcFormatLine(wcCount(stdin)); |
| if (redirect) return applyRedirect(vfs, projectId, wcOutput, redirect); |
| return { stdout: truncate(wcOutput), stderr: '', exitCode: 0 }; |
| } |
|
|
| |
| const wcLines: string[] = []; |
| const wcTotals = { l: 0, w: 0, c: 0 }; |
|
|
| for (const fp of wcFilePaths) { |
| const wcPath = normalizePath(fp); |
| if (!wcPath) continue; |
| try { |
| const file = await vfs.readFile(projectId, wcPath); |
| if (typeof file.content !== 'string') { |
| wcLines.push(`wc: ${wcPath}: binary file`); |
| continue; |
| } |
| const counts = wcCount(file.content); |
| wcTotals.l += counts.l; |
| wcTotals.w += counts.w; |
| wcTotals.c += counts.c; |
| wcLines.push(wcFormatLine(counts, wcPath)); |
| } catch (e: any) { |
| wcLines.push(`wc: ${wcPath}: ${e?.message || 'file not found'}`); |
| } |
| } |
|
|
| |
| if (wcFilePaths.length > 1) { |
| wcLines.push(wcFormatLine(wcTotals, 'total')); |
| } |
|
|
| const wcOutput = wcLines.join('\n'); |
| if (redirect) return applyRedirect(vfs, projectId, wcOutput, redirect); |
| return { stdout: truncate(wcOutput), stderr: '', exitCode: 0 }; |
| } |
| case 'sort': { |
| |
| const sortFlags = { r: false, n: false, u: false }; |
| let filePath = ''; |
|
|
| for (const a of args) { |
| if (a && a.startsWith('-') && /^-[rnu]+$/.test(a)) { |
| for (const ch of a.slice(1)) { |
| if (ch === 'r') sortFlags.r = true; |
| else if (ch === 'n') sortFlags.n = true; |
| else if (ch === 'u') sortFlags.u = true; |
| } |
| } else if (a) { |
| filePath = a; |
| } |
| } |
|
|
| let inputContent: string; |
| const sortPath = normalizePath(filePath); |
|
|
| if (sortPath) { |
| try { |
| const file = await vfs.readFile(projectId, sortPath); |
| if (typeof file.content !== 'string') { |
| return { stdout: '', stderr: `sort: ${sortPath}: binary file`, exitCode: 1 }; |
| } |
| inputContent = file.content; |
| } catch (e: any) { |
| return { stdout: '', stderr: `sort: ${sortPath}: ${e?.message || 'file not found'}`, exitCode: 1 }; |
| } |
| } else if (stdin !== undefined) { |
| inputContent = stdin; |
| } else { |
| return { stdout: '', stderr: 'sort: no input file or stdin', exitCode: 2 }; |
| } |
|
|
| let lines = inputContent.split(/\r?\n/); |
| if (sortFlags.n) { |
| lines.sort((a, b) => { |
| const na = parseFloat(a) || 0; |
| const nb = parseFloat(b) || 0; |
| return na - nb; |
| }); |
| } else { |
| lines.sort(); |
| } |
| if (sortFlags.r) lines.reverse(); |
| if (sortFlags.u) lines = lines.filter((line, i, arr) => i === 0 || line !== arr[i - 1]); |
|
|
| const sortOutput = lines.join('\n'); |
| if (redirect) return applyRedirect(vfs, projectId, sortOutput, redirect); |
| return { stdout: truncate(sortOutput), stderr: '', exitCode: 0 }; |
| } |
| case 'uniq': { |
| |
| let countPrefix = false; |
| let filePath = ''; |
|
|
| for (const a of args) { |
| if (a === '-c') countPrefix = true; |
| else if (a && !a.startsWith('-')) filePath = a; |
| } |
|
|
| let inputContent: string; |
| const uniqPath = normalizePath(filePath); |
|
|
| if (uniqPath) { |
| try { |
| const file = await vfs.readFile(projectId, uniqPath); |
| if (typeof file.content !== 'string') { |
| return { stdout: '', stderr: `uniq: ${uniqPath}: binary file`, exitCode: 1 }; |
| } |
| inputContent = file.content; |
| } catch (e: any) { |
| return { stdout: '', stderr: `uniq: ${uniqPath}: ${e?.message || 'file not found'}`, exitCode: 1 }; |
| } |
| } else if (stdin !== undefined) { |
| inputContent = stdin; |
| } else { |
| return { stdout: '', stderr: 'uniq: no input file or stdin', exitCode: 2 }; |
| } |
|
|
| const lines = inputContent.split(/\r?\n/); |
| const resultLines: string[] = []; |
| let i = 0; |
| while (i < lines.length) { |
| let count = 1; |
| while (i + count < lines.length && lines[i + count] === lines[i]) count++; |
| resultLines.push(countPrefix ? `${String(count).padStart(7)} ${lines[i]}` : lines[i]); |
| i += count; |
| } |
|
|
| const uniqOutput = resultLines.join('\n'); |
| if (redirect) return applyRedirect(vfs, projectId, uniqOutput, redirect); |
| return { stdout: truncate(uniqOutput), stderr: '', exitCode: 0 }; |
| } |
| case 'tr': { |
| |
| let deleteMode = false; |
| const trArgs: string[] = []; |
|
|
| for (const a of args) { |
| if (a === '-d') deleteMode = true; |
| else trArgs.push(a); |
| } |
|
|
| if (stdin === undefined) { |
| return { stdout: '', stderr: 'tr: no stdin (use with pipe, e.g. cat file | tr ...)', exitCode: 2 }; |
| } |
|
|
| const set1 = trArgs[0] || ''; |
| const set2 = trArgs[1] || ''; |
|
|
| if (!set1) { |
| return { stdout: '', stderr: 'tr: missing SET1\n\nUsage: tr [-d] SET1 [SET2]\n tr \'a-z\' \'A-Z\' — translate lowercase to uppercase\n tr -d \'chars\' — delete characters', exitCode: 2 }; |
| } |
|
|
| |
| const expandRange = (s: string): string => { |
| let result = ''; |
| for (let i = 0; i < s.length; i++) { |
| if (i + 2 < s.length && s[i + 1] === '-') { |
| const start = s.charCodeAt(i); |
| const end = s.charCodeAt(i + 2); |
| for (let c = start; c <= end; c++) result += String.fromCharCode(c); |
| i += 2; |
| } else { |
| result += s[i]; |
| } |
| } |
| return result; |
| }; |
|
|
| const expandedSet1 = expandRange(set1); |
|
|
| if (deleteMode) { |
| const deleteChars = new Set(expandedSet1.split('')); |
| const trOutput = stdin.split('').filter(ch => !deleteChars.has(ch)).join(''); |
| if (redirect) return applyRedirect(vfs, projectId, trOutput, redirect); |
| return { stdout: truncate(trOutput), stderr: '', exitCode: 0 }; |
| } |
|
|
| const expandedSet2 = expandRange(set2); |
| const charMap = new Map<string, string>(); |
| for (let i = 0; i < expandedSet1.length; i++) { |
| charMap.set(expandedSet1[i], expandedSet2[Math.min(i, expandedSet2.length - 1)] || ''); |
| } |
|
|
| const trOutput = stdin.split('').map(ch => charMap.get(ch) ?? ch).join(''); |
| if (redirect) return applyRedirect(vfs, projectId, trOutput, redirect); |
| return { stdout: truncate(trOutput), stderr: '', exitCode: 0 }; |
| } |
| case 'curl': { |
| |
| |
| const curlFlags = { silent: false, head: false, outputFile: '', method: '', headers: [] as string[], body: '', markdown: false }; |
| const curlUrls: string[] = []; |
|
|
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a === '-s' || a === '--silent') { curlFlags.silent = true; continue; } |
| if (a === '-I' || a === '--head') { curlFlags.head = true; continue; } |
| if (a === '--markdown') { curlFlags.markdown = true; continue; } |
| if ((a === '-o' || a === '--output') && args[i + 1]) { curlFlags.outputFile = args[++i]; continue; } |
| if ((a === '-X' || a === '--request') && args[i + 1]) { curlFlags.method = args[++i]; continue; } |
| if ((a === '-H' || a === '--header') && args[i + 1]) { curlFlags.headers.push(args[++i]); continue; } |
| if ((a === '-d' || a === '--data' || a === '--data-raw') && args[i + 1]) { curlFlags.body = args[++i]; continue; } |
| if (!a.startsWith('-') && a) { |
| |
| curlUrls.push(a.includes('://') ? a : 'http://' + a); |
| } |
| } |
|
|
| if (curlUrls.length === 0) { |
| return { |
| stdout: '', |
| stderr: `curl: no URL specified |
| |
| Usage: curl [OPTIONS] URL |
| |
| Options: |
| -s, --silent Suppress progress output |
| -I, --head Show response headers only |
| -o, --output FILE Write output to FILE |
| --markdown Convert fetched HTML to readable markdown |
| |
| Examples: |
| curl localhost/ - compiled index.html |
| curl localhost/about - compiled about page |
| curl -I localhost/ - response headers only |
| curl -s localhost/ | grep '<title>' - pipe to grep |
| curl localhost/ > /output.html - redirect to file |
| curl https://example.com - fetch an external page |
| curl --markdown https://example.com - fetch and convert to readable markdown |
| curl -o /logo.png https://.../x.png - download a binary asset into the project |
| |
| Localhost URLs fetch compiled HTML from the preview engine; external URLs are fetched through the outbound proxy.`, |
| exitCode: 2 |
| }; |
| } |
|
|
| |
| |
| |
| |
| const fetchOneUrl = async (u: string): Promise<ShellResult> => { |
| const external = isExternalCurl(['curl', u]); |
|
|
| if (external) { |
| |
| |
| if (typeof window === 'undefined') { |
| return { stdout: '', stderr: 'curl: external URLs require the browser runtime (open the app to fetch the internet).', exitCode: 1 }; |
| } |
| try { |
| const resp = await fetch('/api/web/fetch', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| url: u, |
| method: curlFlags.method || (curlFlags.head ? 'HEAD' : 'GET'), |
| headers: curlFlags.headers, |
| body: curlFlags.body || undefined, |
| }), |
| }); |
| const data = await resp.json(); |
| if (data.error) { |
| return { stdout: '', stderr: `curl: ${data.error}`, exitCode: 1 }; |
| } |
|
|
| |
| if (curlFlags.outputFile) { |
| const outPath = normalizePath(curlFlags.outputFile); |
| if (!outPath) return { stdout: '', stderr: 'curl: -o: missing file path', exitCode: 2 }; |
| const dirPath = outPath.split('/').slice(0, -1).join('/') || '/'; |
| if (dirPath !== '/') await ensureDirectory(vfs, projectId, dirPath); |
| const content: string | ArrayBuffer = |
| data.encoding === 'base64' ? base64ToArrayBuffer(data.body) : data.body; |
| try { await vfs.createFile(projectId, outPath, content); } |
| catch { await vfs.updateFile(projectId, outPath, content); } |
| const msg = curlFlags.silent ? '' : `Saved to ${outPath}`; |
| return { stdout: msg, stderr: '', exitCode: 0 }; |
| } |
|
|
| |
| if (curlFlags.head) { |
| const headers = [`HTTP/1.1 ${data.status} OK`, `Content-Type: ${data.contentType}`, ''].join('\n'); |
| if (redirect) return applyRedirect(vfs, projectId, headers, redirect); |
| return { stdout: headers, stderr: '', exitCode: 0 }; |
| } |
|
|
| let out: string; |
| if (data.encoding === 'base64') { |
| out = '[binary content omitted; use -o FILE to save]'; |
| } else { |
| out = data.body || ''; |
| if (curlFlags.markdown) { |
| const { htmlToMarkdown } = await import('@/lib/web/extract'); |
| out = htmlToMarkdown(out, u); |
| } |
| } |
| const curlResult: ShellResult = { stdout: truncate(out), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, curlResult.stdout, redirect); |
| return curlResult; |
| } catch (e: any) { |
| return { stdout: '', stderr: `curl: request failed: ${e?.message || 'network error'}`, exitCode: 1 }; |
| } |
| } |
|
|
| |
| let urlPath = '/'; |
| try { |
| const parsed = new URL(u); |
| urlPath = parsed.pathname || '/'; |
| } catch { |
| |
| const pathMatch = u.match(/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/.*)?$/i); |
| urlPath = pathMatch?.[1] || '/'; |
| } |
|
|
| |
| |
| |
| |
| |
| let resolvedPath = urlPath; |
| if (resolvedPath === '/') { |
| resolvedPath = '/index.html'; |
| } else if (resolvedPath.endsWith('/')) { |
| resolvedPath = resolvedPath + 'index.html'; |
| } else if (!resolvedPath.includes('.')) { |
| resolvedPath = resolvedPath + '.html'; |
| } |
|
|
| try { |
| |
| const { VirtualServer } = await import('@/lib/preview/virtual-server'); |
| const project = await vfs.getProject(projectId); |
| const server = new VirtualServer(vfs, projectId, { runtime: project?.settings?.runtime }); |
| const compiled = await server.getCompiledFile(resolvedPath); |
|
|
| if (!compiled) { |
| return { |
| stdout: '', |
| stderr: `curl: 404 Not Found — ${resolvedPath}\n\nThe file does not exist in the project. Check the path and try again.\n\nResolved: ${urlPath} → ${resolvedPath}`, |
| exitCode: 1 |
| }; |
| } |
|
|
| let content = typeof compiled.content === 'string' ? compiled.content : ''; |
|
|
| |
| |
| const { stripPreviewScripts } = await import('@/lib/preview/strip-preview-scripts'); |
| content = stripPreviewScripts(content); |
|
|
| if (curlFlags.head) { |
| |
| const headers = [ |
| 'HTTP/1.1 200 OK', |
| `Content-Type: ${compiled.mimeType || 'text/html'}`, |
| `Content-Length: ${new TextEncoder().encode(content).length}`, |
| '' |
| ].join('\n'); |
| const headResult: ShellResult = { stdout: headers, stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, headResult.stdout, redirect); |
| return headResult; |
| } |
|
|
| if (curlFlags.outputFile) { |
| |
| const outPath = normalizePath(curlFlags.outputFile); |
| if (!outPath) return { stdout: '', stderr: 'curl: -o: missing file path', exitCode: 2 }; |
| const dirPath = outPath.split('/').slice(0, -1).join('/') || '/'; |
| if (dirPath !== '/') await ensureDirectory(vfs, projectId, dirPath); |
| try { await vfs.createFile(projectId, outPath, content); } |
| catch { await vfs.updateFile(projectId, outPath, content); } |
| const msg = curlFlags.silent ? '' : ` % Total Received\n 100 ${content.length} ${content.length}\n\nSaved to ${outPath}`; |
| return { stdout: msg, stderr: '', exitCode: 0 }; |
| } |
|
|
| |
| const curlResult: ShellResult = { stdout: truncate(content), stderr: '', exitCode: 0 }; |
| if (redirect) return applyRedirect(vfs, projectId, curlResult.stdout, redirect); |
| return curlResult; |
| } catch (e: any) { |
| |
| return { stdout: '', stderr: `curl: error compiling ${resolvedPath}: ${e?.message || 'unknown error'}`, exitCode: 1 }; |
| } |
| }; |
|
|
| |
| if (curlUrls.length === 1) { |
| return await fetchOneUrl(curlUrls[0]); |
| } |
|
|
| |
| const curlStdouts: string[] = []; |
| const curlStderrs: string[] = []; |
| let curlExitCode = 0; |
| for (const u of curlUrls) { |
| const r = await fetchOneUrl(u); |
| if (r.stdout) curlStdouts.push(r.stdout); |
| if (r.stderr) curlStderrs.push(r.stderr); |
| if (r.exitCode !== 0) curlExitCode = 1; |
| } |
| return { |
| stdout: curlStdouts.join('\n\n'), |
| stderr: curlStderrs.join('\n'), |
| exitCode: curlExitCode, |
| }; |
| } |
| case 'search': { |
| if (typeof window === 'undefined') { |
| return { stdout: '', stderr: 'search: requires the browser runtime.', exitCode: 1 }; |
| } |
| const { configManager } = await import('@/lib/config/storage'); |
| const provider = configManager.getWebSearchProvider(); |
| if (!provider) { |
| return { stdout: '', stderr: 'search: no web search provider configured. Add one under Connections (Settings).', exitCode: 1 }; |
| } |
|
|
| let count = 5; |
| let markdown = false; |
| const queryParts: string[] = []; |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if ((a === '-n' || a === '--count') && args[i + 1]) { count = parseInt(args[++i], 10) || 5; continue; } |
| if (a === '--markdown') { markdown = true; continue; } |
| if (a) queryParts.push(a); |
| } |
| const query = queryParts.join(' ').trim(); |
| if (!query) { |
| return { stdout: '', stderr: 'Usage: search [-n N] [--markdown] "query"', exitCode: 1 }; |
| } |
|
|
| const auth = provider === 'searxng' |
| ? { searxngUrl: configManager.getSearxngUrl() || undefined } |
| : { key: configManager.getWebSearchKey(provider) || undefined }; |
|
|
| try { |
| const resp = await fetch('/api/web/search', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ provider, query, count, markdown, auth }), |
| }); |
| const data = await resp.json(); |
| if (data.error) return { stdout: '', stderr: `search: ${data.error}`, exitCode: 1 }; |
| const results: Array<{ title: string; url: string; snippet: string; content?: string }> = data.results || []; |
| if (results.length === 0) return { stdout: 'No results.', stderr: '', exitCode: 0 }; |
|
|
| |
| |
| const { WEB_SEARCH_PROVIDERS } = await import('@/lib/web-search'); |
| const nativeContent = WEB_SEARCH_PROVIDERS[provider].nativeContent; |
| if (markdown && !nativeContent) { |
| const top = results.slice(0, Math.min(3, results.length)); |
| await Promise.all(top.map(async (r) => { |
| try { |
| const fr = await fetch('/api/web/fetch', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ url: r.url }), |
| }); |
| const fd = await fr.json(); |
| if (!fd.error && fd.encoding !== 'base64' && fd.body) { |
| const { htmlToMarkdown } = await import('@/lib/web/extract'); |
| r.content = htmlToMarkdown(fd.body, r.url); |
| } |
| } catch { } |
| })); |
| } |
|
|
| const lines: string[] = []; |
| results.forEach((r, i) => { |
| lines.push(`${i + 1}. ${r.title}`); |
| lines.push(` ${r.url}`); |
| if (r.snippet) lines.push(` ${r.snippet}`); |
| if (markdown && r.content) { |
| lines.push(''); |
| lines.push(r.content.slice(0, 2000)); |
| } |
| lines.push(''); |
| }); |
| return { stdout: truncate(lines.join('\n').trim()), stderr: '', exitCode: 0 }; |
| } catch (e: any) { |
| return { stdout: '', stderr: `search: request failed: ${e?.message || 'network error'}`, exitCode: 1 }; |
| } |
| } |
| case 'sqlite3': { |
| |
| |
| return { |
| stdout: '', |
| stderr: `sqlite3: requires Server Mode with a published deployment |
| |
| The sqlite3 command requires: |
| 1. Server Mode (not Browser Mode) |
| 2. A deployment to be selected and published |
| |
| If you are in Server Mode with a published deployment, this error indicates the deployment context is not set. |
| Please ensure the deployment is selected in the workspace before using sqlite3. |
| |
| Alternative: Use edge functions for database access via db.query() and db.run()`, |
| exitCode: 1 |
| }; |
| } |
| case 'build': { |
| |
| |
| |
| try { |
| const { VirtualServer } = await import('@/lib/preview/virtual-server'); |
| const buildProject = await vfs.getProject(projectId); |
| const server = new VirtualServer(vfs, projectId, { runtime: buildProject?.settings?.runtime }); |
| await server.compileProject(); |
| server.cleanupBlobUrls(); |
|
|
| const compileErrors = drainCompileErrors(); |
| if (compileErrors.length === 0) { |
| return { stdout: 'Build successful — 0 errors', stderr: '', exitCode: 0 }; |
| } |
| return { stdout: '', stderr: formatCompileErrors(compileErrors), exitCode: 1 }; |
| } catch (err: any) { |
| return { stdout: '', stderr: `Build failed: ${err.message}`, exitCode: 1 }; |
| } |
| } |
| case 'generate-image': { |
| |
| |
| if (!ctx?.generateImage) { |
| return { stdout: '', stderr: "generate-image: no image-generation model is configured for this project. Set one under the project's models.", exitCode: 1 }; |
| } |
| let outPath: string | undefined; |
| let aspectRatio: string | undefined; |
| let imageSize: string | undefined; |
| const promptParts: string[] = []; |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a === '--out' || a === '-o') outPath = args[++i]; |
| else if (a === '--aspect' || a === '--aspect-ratio') aspectRatio = args[++i]; |
| else if (a === '--size') imageSize = args[++i]; |
| else promptParts.push(a); |
| } |
| const prompt = promptParts.join(' ').trim(); |
| if (!prompt) { |
| return { stdout: '', stderr: 'Usage: generate-image [--out <path>] [--aspect <ratio>] [--size <0.5K|1K|2K|4K>] "<prompt>"', exitCode: 1 }; |
| } |
| let image: { base64: string; mimeType: string }; |
| try { |
| image = await ctx.generateImage(prompt, { aspectRatio, imageSize }); |
| } catch (err) { |
| return { stdout: '', stderr: `generate-image: ${err instanceof Error ? err.message : 'generation failed'}`, exitCode: 1 }; |
| } |
| |
| const binary = atob(image.base64); |
| const bytes = new Uint8Array(binary.length); |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| const ext = (image.mimeType.split('/')[1] || 'png').replace('jpeg', 'jpg').replace('+xml', ''); |
| const slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'image'; |
| const target = outPath |
| ? (normalizePath(outPath) || outPath) |
| : `/.generated/${slug}-${Date.now().toString(36).slice(-5)}.${ext}`; |
| const dirPath = target.slice(0, target.lastIndexOf('/')) || '/'; |
| if (dirPath !== '/') await ensureDirectory(vfs, projectId, dirPath); |
| try { |
| await vfs.createFile(projectId, target, bytes.buffer as ArrayBuffer); |
| } catch { |
| await vfs.updateFile(projectId, target, bytes.buffer as ArrayBuffer); |
| } |
| const detail = [aspectRatio && `aspect ${aspectRatio}`, imageSize && `size ${imageSize}`].filter(Boolean).join(', '); |
| const note = outPath ? '' : ' (/.generated/ is excluded from the published build; pass --out <path> to save into the site).'; |
| return { |
| stdout: `Generated image saved to ${target} (${bytes.length} bytes${detail ? `, ${detail}` : ''}).${note}`, |
| stderr: '', |
| exitCode: 0, |
| }; |
| } |
| case 'runtime': { |
| |
| |
| const VALID_RUNTIMES = ['static', 'handlebars', 'react', 'preact', 'svelte', 'vue', 'python', 'lua']; |
| const requested = args[0]?.toLowerCase(); |
| if (!requested || !VALID_RUNTIMES.includes(requested)) { |
| return { |
| stdout: '', |
| stderr: `Usage: runtime <name>\nValid runtimes: ${VALID_RUNTIMES.join(', ')}`, |
| exitCode: 1 |
| }; |
| } |
| try { |
| const proj = await vfs.getProject(projectId); |
| if (!proj) { |
| return { stdout: '', stderr: 'Project not found', exitCode: 1 }; |
| } |
| const currentRuntime = proj.settings?.runtime || 'static'; |
| if (currentRuntime === requested) { |
| return { stdout: `Runtime already set to ${requested}`, stderr: '', exitCode: 0 }; |
| } |
| const runtime = requested as import('@/lib/vfs/types').ProjectRuntime; |
| proj.settings = { ...proj.settings, runtime }; |
| await vfs.updateProject(proj); |
|
|
| |
| |
| |
| const { importWithRetry } = await import('./import-retry'); |
| const { getDomainPrompt, isDefaultDomainPrompt } = await importWithRetry(() => import('@/lib/llm/prompts')); |
| const newPrompt = getDomainPrompt(runtime); |
| try { |
| const existing = await vfs.readFile(projectId, '/.PROMPT.md'); |
| if (isDefaultDomainPrompt(typeof existing.content === 'string' ? existing.content : '')) { |
| await vfs.updateFile(projectId, '/.PROMPT.md', newPrompt); |
| } |
| |
| } catch { |
| |
| await vfs.createFile(projectId, '/.PROMPT.md', newPrompt); |
| } |
|
|
| |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('runtimeChanged', { detail: { runtime } })); |
| } |
|
|
| track('runtime_switch', { from: currentRuntime, to: runtime }); |
|
|
| return { stdout: `Runtime changed to ${requested}`, stderr: '', exitCode: 0 }; |
| } catch (err: any) { |
| return { stdout: '', stderr: `Failed to change runtime: ${err.message}`, exitCode: 1 }; |
| } |
| } |
| case 'status': { |
| |
| |
| const flags: Record<string, string> = {}; |
| let currentFlag: string | null = null; |
| const tokens: string[] = []; |
| let isComplete = false; |
| let isIncomplete = false; |
| for (const arg of args) { |
| if (arg === '--complete') { |
| if (currentFlag && tokens.length > 0) { |
| flags[currentFlag] = tokens.join(' '); |
| tokens.length = 0; |
| } |
| currentFlag = null; |
| isComplete = true; |
| } else if (arg === '--incomplete') { |
| if (currentFlag && tokens.length > 0) { |
| flags[currentFlag] = tokens.join(' '); |
| tokens.length = 0; |
| } |
| currentFlag = null; |
| isIncomplete = true; |
| } else if (arg === '--task' || arg === '--done' || arg === '--remaining') { |
| if (currentFlag && tokens.length > 0) { |
| flags[currentFlag] = tokens.join(' '); |
| tokens.length = 0; |
| } |
| currentFlag = arg.slice(2); |
| } else if (currentFlag) { |
| tokens.push(arg); |
| } |
| } |
| if (currentFlag && tokens.length > 0) { |
| flags[currentFlag] = tokens.join(' '); |
| } |
|
|
| if (!flags.task || !flags.done) { |
| return { |
| stdout: '', |
| stderr: 'Usage: status --task "what was asked" --done "what I accomplished" --remaining "what\'s left or none" --complete', |
| exitCode: 1 |
| }; |
| } |
| const remaining = flags.remaining || 'none'; |
| |
| const complete = isComplete && !isIncomplete; |
| |
| |
| |
| return { |
| stdout: `Status recorded.\nRemaining: ${remaining}\nComplete: ${complete ? 'yes' : 'no'}`, |
| stderr: '', |
| exitCode: 0 |
| }; |
| } |
| case 'ask': { |
| |
| |
| |
| let askPrompt: string | undefined; |
| const options: string[] = []; |
| for (let i = 0; i < args.length; i++) { |
| const a = args[i]; |
| if (a === '--prompt' && args[i + 1] !== undefined) { |
| askPrompt = args[++i]; |
| } else if (a) { |
| options.push(a); |
| } |
| } |
| if (options.length < 2) { |
| return { |
| stdout: '', |
| stderr: 'Usage: ask [--prompt "Question"] "Option A" "Option B" ["Option C" ...]\nNeed at least two options.', |
| exitCode: 1 |
| }; |
| } |
| ctx?.onProgress?.('ask', { prompt: askPrompt, options }); |
| return { |
| stdout: `Awaiting user selection. Options presented: ${options.map(o => `"${o}"`).join(', ')}`, |
| stderr: '', |
| exitCode: 0, |
| exitReason: 'awaiting_user' |
| }; |
| } |
| case 'brief': { |
| |
| |
| const mode = args[0]; |
| if (mode !== '--merge') { |
| return { |
| stdout: '', |
| stderr: 'Usage: brief --merge << \'EOF\'\n{ ...JSON... }\nEOF', |
| exitCode: 1 |
| }; |
| } |
| if (!stdin || !stdin.trim()) { |
| return { |
| stdout: '', |
| stderr: 'brief --merge: expected JSON body via heredoc (<< \'EOF\' ... EOF).', |
| exitCode: 1 |
| }; |
| } |
| let parsed: any; |
| try { |
| parsed = JSON.parse(stdin.trim()); |
| } catch (err: any) { |
| return { |
| stdout: '', |
| stderr: `brief --merge: invalid JSON — ${err.message}`, |
| exitCode: 1 |
| }; |
| } |
| if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { |
| return { |
| stdout: '', |
| stderr: 'brief --merge: body must be a JSON object.', |
| exitCode: 1 |
| }; |
| } |
| ctx?.onProgress?.('brief_update', { brief: parsed }); |
| const fields = Object.keys(parsed); |
| return { |
| stdout: fields.length > 0 ? `Brief updated: ${fields.join(', ')}` : 'Brief unchanged.', |
| stderr: '', |
| exitCode: 0 |
| }; |
| } |
| case 'spec': { |
| |
| |
| |
| const mode = args[0]; |
| if (mode !== '--append') { |
| return { |
| stdout: '', |
| stderr: 'Usage: spec --append "Section heading" << \'EOF\'\nprose\nEOF', |
| exitCode: 1 |
| }; |
| } |
| const section = args[1]; |
| if (!section || typeof section !== 'string' || !section.trim()) { |
| return { |
| stdout: '', |
| stderr: 'spec --append: section heading required as second argument.', |
| exitCode: 1 |
| }; |
| } |
| if (!stdin || !stdin.trim()) { |
| return { |
| stdout: '', |
| stderr: 'spec --append: expected prose body via heredoc (<< \'EOF\' ... EOF).', |
| exitCode: 1 |
| }; |
| } |
| ctx?.onProgress?.('spec_update', { |
| section: section.trim(), |
| content: stdin.trim() |
| }); |
| return { |
| stdout: `Spec updated: ${section.trim()}`, |
| stderr: '', |
| exitCode: 0 |
| }; |
| } |
| case 'propose-create': { |
| |
| |
| |
| ctx?.onProgress?.('project_ready', {}); |
| return { |
| stdout: 'Project ready to create. The user can review the brief and click "Create now" to confirm.', |
| stderr: '', |
| exitCode: 0, |
| exitReason: 'setup_propose_create' |
| }; |
| } |
| case 'ss': { |
| |
| |
| |
|
|
| |
| let ssMode: 'literal' | 'entity' | 'fuzzy' | 'regex' = 'literal'; |
| let ssFilePath = ''; |
| for (const a of args) { |
| if (a === '--entity' || a === '-e') ssMode = 'entity'; |
| else if (a === '--fuzzy' || a === '-f') ssMode = 'fuzzy'; |
| else if (a === '--regex' || a === '-r') ssMode = 'regex'; |
| else if (a && !a.startsWith('-')) ssFilePath = a; |
| } |
|
|
| const ssPath = normalizePath(ssFilePath); |
| if (!ssPath) return { stdout: '', stderr: 'ss: missing file path', exitCode: 2 }; |
|
|
| if (stdin === undefined || stdin === '') { |
| return { stdout: '', stderr: 'ss: missing heredoc input (use ss /file << \'EOF\')', exitCode: 2 }; |
| } |
|
|
| |
| const sepIdx = stdin.indexOf('\n=======\n'); |
| let ssSearch: string; |
| let ssReplace: string; |
| if (sepIdx !== -1) { |
| ssSearch = stdin.substring(0, sepIdx); |
| ssReplace = stdin.substring(sepIdx + '\n=======\n'.length); |
| } else if (ssMode === 'entity') { |
| |
| |
| const firstLine = stdin.split('\n')[0].trim(); |
| ssSearch = firstLine; |
| ssReplace = stdin; |
| } else { |
| return { stdout: '', stderr: 'ss: missing ======= separator between search and replacement\n\nUsage: ss /file << \'EOF\'\nsearch content\n=======\nreplacement content\nEOF', exitCode: 2 }; |
| } |
|
|
| |
| let ssContent: string; |
| try { |
| const file = await vfs.readFile(projectId, ssPath); |
| if (typeof file.content !== 'string') { |
| return { stdout: '', stderr: `ss: ${ssPath}: binary file`, exitCode: 1 }; |
| } |
| ssContent = file.content; |
| } catch (e: any) { |
| return { stdout: '', stderr: `ss: ${ssPath}: ${e?.message || 'file not found'}`, exitCode: 1 }; |
| } |
|
|
| let ssResult: string; |
|
|
| switch (ssMode) { |
| case 'literal': { |
| const idx = ssContent.indexOf(ssSearch); |
| if (idx === -1) { |
| const preview = ssSearch.length > 200 ? ssSearch.substring(0, 200) + '...' : ssSearch; |
| return { stdout: '', stderr: `ss: search text not found in ${ssPath}\n\nSearched for:\n${preview}`, exitCode: 1 }; |
| } |
| ssResult = ssContent.substring(0, idx) + ssReplace + ssContent.substring(idx + ssSearch.length); |
| break; |
| } |
| case 'entity': { |
| const selectorMatch = ssFindSelectorMatch(ssContent, ssSearch); |
| if (!selectorMatch) { |
| const preview = ssSearch.length > 200 ? ssSearch.substring(0, 200) + '...' : ssSearch; |
| return { stdout: '', stderr: `ss --entity: selector not found in ${ssPath}\n\nSearched for:\n${preview}`, exitCode: 1 }; |
| } |
| const isHtml = ssIsHtmlEntity(selectorMatch.normalizedSelector); |
| const boundary = ssDetectEntityBoundary(ssContent, selectorMatch.index, selectorMatch.normalizedSelector, isHtml); |
| if (!boundary) { |
| return { stdout: '', stderr: `ss --entity: could not detect entity boundary for selector in ${ssPath}`, exitCode: 1 }; |
| } |
| ssResult = ssContent.substring(0, boundary.start) + ssReplace + ssContent.substring(boundary.end); |
| break; |
| } |
| case 'fuzzy': { |
| const normalizeForFuzzy = (s: string) => s.split('\n').map(l => l.trim()).filter(l => l.length > 0).join(' ').replace(/\s+/g, ' '); |
| const normalizedSearch = normalizeForFuzzy(ssSearch); |
| const origRange = ssMapNormalizedToOriginal(ssContent, normalizedSearch); |
| if (!origRange) { |
| const preview = ssSearch.length > 200 ? ssSearch.substring(0, 200) + '...' : ssSearch; |
| return { stdout: '', stderr: `ss -f: search text not found (even with whitespace normalization) in ${ssPath}\n\nSearched for:\n${preview}`, exitCode: 1 }; |
| } |
| ssResult = ssContent.substring(0, origRange.start) + ssReplace + ssContent.substring(origRange.end); |
| break; |
| } |
| case 'regex': { |
| let re: RegExp; |
| try { |
| re = new RegExp(ssSearch, 's'); |
| } catch (e: any) { |
| return { stdout: '', stderr: `ss -r: invalid regex: ${e?.message || 'parse error'}`, exitCode: 2 }; |
| } |
| const m = re.exec(ssContent); |
| if (!m) { |
| const preview = ssSearch.length > 200 ? ssSearch.substring(0, 200) + '...' : ssSearch; |
| return { stdout: '', stderr: `ss -r: regex did not match in ${ssPath}\n\nPattern:\n${preview}`, exitCode: 1 }; |
| } |
| |
| |
| const expandedReplace = ssReplace |
| .replace(/\$\$/g, '\x00DOLLAR\x00') |
| .replace(/\$(\d+)/g, (_, idx) => m[Number(idx)] || '') |
| .replace(/\x00DOLLAR\x00/g, '$'); |
| ssResult = ssContent.substring(0, m.index) + expandedReplace + ssContent.substring(m.index + m[0].length); |
| break; |
| } |
| } |
|
|
| |
| try { |
| await vfs.updateFile(projectId, ssPath, ssResult); |
| return { stdout: `(1 replacement in ${ssPath})`, stderr: '', exitCode: 0 }; |
| } catch (e: any) { |
| return { stdout: '', stderr: `ss: ${ssPath}: ${e?.message || 'cannot write file'}`, exitCode: 1 }; |
| } |
| } |
| case 'sleep': { |
| |
| |
| return { stdout: '', stderr: '', exitCode: 0 }; |
| } |
| default: { |
| const bashHint = program === 'bash' ? ` |
| Don't use "bash" as a command - call the bash tool directly with your command. |
| Wrong: {"command": "bash -c ls -la"} |
| Right: {"command": "ls -la"} |
| ` : ''; |
|
|
| return { |
| stdout: '', |
| stderr: `${program}: command not found${bashHint} |
| |
| Supported commands: ls, tree, cat, head, tail, rg, grep, find, mkdir, touch, rm, mv, cp, echo, sed, ss, wc, sort, uniq, tr, curl, search, sleep, sqlite3, build, status |
| Operators: | (pipe), > (redirect), >> (append), && (chain), || (fallback), ; (sequence) |
| |
| Correct shell tool usage: |
| {"cmd": ["ls", "/"]} - List files |
| {"cmd": ["ls", "-R", "/"]} - List files recursively |
| {"cmd": ["tree", "/", "-L", "2"]} - Show directory tree (max depth 2) |
| {"cmd": ["cat", "/file.txt"]} - Read entire file |
| {"cmd": ["head", "-n", "20", "/file.txt"]} - Read first 20 lines |
| {"cmd": ["tail", "-n", "20", "/file.txt"]} - Read last 20 lines |
| {"cmd": ["rg", "-C", "3", "pattern", "/"]} - Search with 3 lines context (recommended) |
| {"cmd": ["rg", "-A", "2", "-B", "1", "pattern"]} - Search with custom context |
| {"cmd": ["grep", "-n", "pattern", "/file.txt"]} - Search with line numbers |
| {"cmd": ["grep", "-F", "literal", "/file.txt"]} - Search literal string |
| {"cmd": ["find", "/", "-name", "*.js"]} - Find files by name |
| {"cmd": ["mkdir", "-p", "/path/to/dir"]} - Create directory (with parents) |
| {"cmd": ["touch", "/file.txt"]} - Create empty file |
| {"cmd": ["rm", "-rf", "/dirname"]} - Delete directory recursively |
| {"cmd": ["mv", "/old.txt", "/new.txt"]} - Move/rename files |
| {"cmd": ["cp", "-r", "/src", "/dest"]} - Copy files/directories |
| {"cmd": ["echo", "Hello World"]} - Output text |
| {"cmd": ["echo", "content", ">", "/file.txt"]} - Write text to file |
| {"cmd": ["sed", "s/old/new/g", "/file.txt"]} - Text substitution (stdout) |
| {"cmd": ["sed", "-i", "s/old/new/g", "/file.txt"]} - In-place edit |
| {"cmd": ["cat", "/f.txt", "|", "grep", "class", "|", "head", "-n", "5"]} - Pipe chain |
| {"cmd": ["grep", "-n", "div", "/f.txt", ">", "/results.txt"]} - Redirect to file |
| {"cmd": ["find", "/", "-type", "f", "|", "wc", "-l"]} - Count files |
| {"cmd": ["wc", "-l", "/file.txt"]} - Count lines in file |
| {"cmd": ["curl", "localhost/"]} - View compiled HTML output |
| {"cmd": ["curl", "localhost/about"]} - View compiled page (path resolution) |
| {"cmd": ["curl", "-I", "localhost/"]} - Response headers only |
| {"cmd": ["search", "query"]} - Web search via configured provider |
| {"cmd": ["sqlite3", "SELECT * FROM users"]} - Execute SQL (Server Mode) |
| {"cmd": ["sqlite3", "-json", "SELECT * FROM products"]} - SQL output as JSON |
| |
| Note: Use ss for editing existing files, cat > for new file creation, sed -i for single-line substitutions. Use rg (ripgrep) instead of grep for better context management. |
| Note: sqlite3 is only available in Server Mode and when a deployment context is selected.`, |
| exitCode: 127 |
| }; |
| } |
| } |
| } catch (e: any) { |
| return { stdout: '', stderr: e?.message || String(e), exitCode: 1 }; |
| } |
| } |
|
|
| |
| export const vfsShell = { |
| execute: async ( |
| projectId: string, |
| cmd: string[], |
| stdin?: string, |
| ctx?: ShellContext |
| ): Promise<{ success: boolean; stdout?: string; stderr?: string; exitReason?: string }> => { |
| const { getActiveVFS } = await import('./index'); |
| const activeVFS = getActiveVFS(); |
| await activeVFS.init(); |
| const result = await vfsShellExecute(activeVFS, projectId, cmd, stdin, ctx); |
| return { |
| success: result.exitCode === 0, |
| stdout: result.stdout, |
| stderr: result.stderr, |
| exitReason: result.exitReason |
| }; |
| } |
| }; |
|
|