Last active
August 13, 2026 05:19
-
-
Save MagicDippyEgg/c746ae2e2fa8135c6d7732d1de9d0cad to your computer and use it in GitHub Desktop.
cloudflare bash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export default { | |
| async fetch(request, env, ctx) { | |
| // 1. Handle CORS Options Pre-flight for AI Clients | |
| if (request.method === "OPTIONS") { | |
| return new Response(null, { | |
| headers: { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |
| "Access-Control-Allow-Headers": "Content-Type, Authorization", | |
| }, | |
| }); | |
| } | |
| const url = new URL(request.url); | |
| // 2. Base Endpoint or Health Check | |
| if (request.method === "GET") { | |
| return new Response(JSON.stringify({ status: "online", protocol: "mcp" }), { | |
| status: 200, | |
| headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, | |
| }); | |
| } | |
| // 3. Handle MCP Routing via POST | |
| if (request.method === "POST") { | |
| try { | |
| const body = await request.json(); | |
| const { method, params, id } = body; | |
| // MCP Initialization Handshake | |
| if (method === "initialize") { | |
| return jsonResponse({ | |
| jsonrpc: "2.0", | |
| id: id, | |
| result: { | |
| protocolVersion: "2024-11-05", | |
| capabilities: { tools: {} }, | |
| serverInfo: { name: "cloudflare-persistent-linux-sandbox", version: "1.1.0" } | |
| } | |
| }); | |
| } | |
| // List Available Tools to the AI Client | |
| if (method === "tools/list") { | |
| return jsonResponse({ | |
| jsonrpc: "2.0", | |
| id: id, | |
| result: { | |
| tools: [ | |
| { | |
| name: "linux_bash_cmd", | |
| description: "Simulates bash commands (help, python, python3, mkdir, ls, echo, rm, rmdir, cat, touch, curl, wget, grep, uname, whoami, env, printenv, export, date, cd, pwd, hostname, hostnamectl, id, groups, uptime, free, df, du, nproc, arch, ps, top, kill, which, type, chmod, sleep, history, head, tail, wc, sort, uniq, find, mv, cp, ln, cut, tr, seq, yes, true, false, nl, tee, diff, stat, file, basename, dirname) in a persistent workspace context. Run `help` for a category overview or `help <command>` for usage on a specific command. Also supports running simple .sh scripts written to the workspace via `bash <file>` or `sh <file>` (or `./<file>` for scripts marked executable) - each non-comment line is executed in turn using the same simulated commands listed above. Includes a lightweight Python interpreter for basic scripts and math.", | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| command: { type: "string", description: "The full bash command string to execute." } | |
| }, | |
| required: ["command"] | |
| } | |
| }, | |
| { | |
| name: "read_file", | |
| description: "Reads a text file directly from the persistent cloud workspace.", | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| path: { type: "string", description: "The relative file path to read." } | |
| }, | |
| required: ["path"] | |
| } | |
| } | |
| ] | |
| } | |
| }); | |
| } | |
| // Handle Tool Invocations | |
| if (method === "tools/call") { | |
| const toolName = params.name; | |
| const args = params.arguments || {}; | |
| if (toolName === "linux_bash_cmd") { | |
| const resultText = await handleBashSimulation(args.command, env.SANDBOX_STORE); | |
| return jsonResponse({ | |
| jsonrpc: "2.0", | |
| id: id, | |
| result: { content: [{ type: "text", text: resultText }] } | |
| }); | |
| } | |
| if (toolName === "read_file") { | |
| const cwd = await env.SANDBOX_STORE.get("sys:cwd") || "/"; | |
| const absPath = resolvePath(args.path, cwd); | |
| const fileData = await env.SANDBOX_STORE.get(`file:${absPath}`); | |
| const textOutput = fileData !== null ? fileData : `Error: File '${args.path}' not found.`; | |
| return jsonResponse({ | |
| jsonrpc: "2.0", | |
| id: id, | |
| result: { content: [{ type: "text", text: textOutput }] } | |
| }); | |
| } | |
| return jsonResponse({ | |
| jsonrpc: "2.0", | |
| id: id, | |
| error: { code: -32601, message: "Method not found" } | |
| }, 404); | |
| } | |
| // Default MCP Safe Return | |
| return jsonResponse({ jsonrpc: "2.0", id: id, result: {} }); | |
| } catch (err) { | |
| return jsonResponse({ error: "Invalid JSON or Internal Server Error", details: err.message }, 500); | |
| } | |
| } | |
| return new Response("Not Found", { status: 404 }); | |
| }, | |
| }; | |
| // Helper function to return properly formatted JSON with cross-origin clearances | |
| function jsonResponse(data, status = 200) { | |
| return new Response(JSON.stringify(data), { | |
| status: status, | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Access-Control-Allow-Origin": "*", | |
| }, | |
| }); | |
| } | |
| // Splits a command string on the given top-level operators (e.g. "&&", "|"), | |
| // ignoring anything inside single or double quotes. Returns the segments | |
| // with the operators removed (operators are matched longest-first via the | |
| // order of the `operators` array). | |
| function splitTopLevel(text, operators) { | |
| const segments = []; | |
| let current = ""; | |
| let inSingle = false; | |
| let inDouble = false; | |
| for (let i = 0; i < text.length; i++) { | |
| const ch = text[i]; | |
| if (ch === "\\" && i + 1 < text.length && !inSingle) { | |
| current += ch; | |
| current += text[i + 1]; | |
| i++; | |
| continue; | |
| } | |
| if (ch === "'" && !inDouble) { | |
| inSingle = !inSingle; | |
| current += ch; | |
| continue; | |
| } | |
| if (ch === '"' && !inSingle) { | |
| inDouble = !inDouble; | |
| current += ch; | |
| continue; | |
| } | |
| if (!inSingle && !inDouble) { | |
| const matchedOp = operators.find(op => text.startsWith(op, i)); | |
| if (matchedOp) { | |
| segments.push(current); | |
| current = ""; | |
| i += matchedOp.length - 1; | |
| continue; | |
| } | |
| } | |
| current += ch; | |
| } | |
| segments.push(current); | |
| return segments; | |
| } | |
| // Splits a command string into logical lines, treating newlines inside | |
| // single/double quotes as part of the quoted text (heredoc-style echo | |
| // blocks) instead of line separators. | |
| function splitLogicalLines(text) { | |
| const lines = []; | |
| let current = ""; | |
| let inSingle = false; | |
| let inDouble = false; | |
| for (let i = 0; i < text.length; i++) { | |
| const ch = text[i]; | |
| if (ch === "\\" && i + 1 < text.length && !inSingle) { | |
| current += ch; | |
| current += text[i + 1]; | |
| i++; | |
| continue; | |
| } | |
| if (ch === "'" && !inDouble) { | |
| inSingle = !inSingle; | |
| current += ch; | |
| continue; | |
| } | |
| if (ch === '"' && !inSingle) { | |
| inDouble = !inDouble; | |
| current += ch; | |
| continue; | |
| } | |
| if (ch === "\n" && !inSingle && !inDouble) { | |
| lines.push(current); | |
| current = ""; | |
| continue; | |
| } | |
| current += ch; | |
| } | |
| lines.push(current); | |
| return lines.map(l => l.trim()).filter(Boolean); | |
| } | |
| // Splits a command string into chain segments on the top-level operators | |
| // &&, || and ;, keeping the connector that preceded each segment so the | |
| // caller can honor short-circuit semantics. Quotes are respected. | |
| function splitChain(text) { | |
| const ops = ["&&", "||", ";"]; | |
| const segments = []; | |
| let current = ""; | |
| let currentOp = null; | |
| let inSingle = false; | |
| let inDouble = false; | |
| for (let i = 0; i < text.length; i++) { | |
| const ch = text[i]; | |
| if (ch === "\\" && i + 1 < text.length && !inSingle) { | |
| current += ch; | |
| current += text[i + 1]; | |
| i++; | |
| continue; | |
| } | |
| if (ch === "'" && !inDouble) { | |
| inSingle = !inSingle; | |
| current += ch; | |
| continue; | |
| } | |
| if (ch === '"' && !inSingle) { | |
| inDouble = !inDouble; | |
| current += ch; | |
| continue; | |
| } | |
| if (!inSingle && !inDouble) { | |
| const matchedOp = ops.find(op => text.startsWith(op, i)); | |
| if (matchedOp) { | |
| segments.push({ op: currentOp, cmd: current }); | |
| current = ""; | |
| currentOp = matchedOp; | |
| i += matchedOp.length - 1; | |
| continue; | |
| } | |
| } | |
| current += ch; | |
| } | |
| segments.push({ op: currentOp, cmd: current }); | |
| return segments; | |
| } | |
| // Builds the simulated environment variable map: fixed defaults (HOME, USER, | |
| // SHELL, PATH), a dynamic PWD tied to the current working dir, and whatever | |
| // the user has 'export'-ed on top, which take precedence over the defaults. | |
| function buildEnvVars(currentDir, customEnv) { | |
| return { | |
| SHELL: "/bin/bash", | |
| USER: "worker", | |
| HOME: "/home/worker", | |
| PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", | |
| PWD: currentDir, | |
| ...customEnv, | |
| }; | |
| } | |
| // Tokenizes a shell command string into an array of tokens, honoring | |
| // single-quote (literal text - no expansion or escaping), double-quote | |
| // (variable expansion plus limited escaping), and unquoted backslash | |
| // escapes. $VAR/${VAR} references expand against the given env var map; | |
| // unset variables expand to "", matching real shell behavior. | |
| function tokenizeShell(text, envVars) { | |
| const tokens = []; | |
| let current = ""; | |
| let i = 0; | |
| const push = () => { | |
| if (current !== "") { tokens.push(current); current = ""; } | |
| }; | |
| while (i < text.length) { | |
| const ch = text[i]; | |
| if (ch === "'") { | |
| i++; | |
| while (i < text.length && text[i] !== "'") { | |
| current += text[i]; | |
| i++; | |
| } | |
| i++; | |
| continue; | |
| } | |
| if (ch === '"') { | |
| i++; | |
| while (i < text.length && text[i] !== '"') { | |
| const c = text[i]; | |
| if (c === "\\" && i + 1 < text.length && ['"', "\\", "$", "`"].includes(text[i + 1])) { | |
| current += text[i + 1]; | |
| i += 2; | |
| continue; | |
| } | |
| if (c === "$") { | |
| const expansion = matchVariable(text, i); | |
| if (expansion) { | |
| current += envVars[expansion.name] !== undefined ? envVars[expansion.name] : ""; | |
| i = expansion.end; | |
| continue; | |
| } | |
| } | |
| current += c; | |
| i++; | |
| } | |
| i++; | |
| continue; | |
| } | |
| if (ch === "\\" && i + 1 < text.length) { | |
| current += text[i + 1]; | |
| i += 2; | |
| continue; | |
| } | |
| if (ch === "$") { | |
| const expansion = matchVariable(text, i); | |
| if (expansion) { | |
| current += envVars[expansion.name] !== undefined ? envVars[expansion.name] : ""; | |
| i = expansion.end; | |
| continue; | |
| } | |
| } | |
| if (/\s/.test(ch)) { | |
| push(); | |
| i++; | |
| continue; | |
| } | |
| current += ch; | |
| i++; | |
| } | |
| push(); | |
| return tokens; | |
| } | |
| // Matches $NAME or ${NAME} starting at index i in `text`. Returns | |
| // { name, end } when a valid variable reference is found, otherwise null. | |
| function matchVariable(text, i) { | |
| if (text[i] !== "$") return null; | |
| if (text[i + 1] === "{") { | |
| const close = text.indexOf("}", i + 2); | |
| if (close !== -1) { | |
| const name = text.slice(i + 2, close); | |
| if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return { name, end: close + 1 }; | |
| } | |
| return null; | |
| } | |
| const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i + 1)); | |
| if (m) return { name: m[0], end: i + 1 + m[0].length }; | |
| return null; | |
| } | |
| // Normalizes a path, resolving "." and ".." segments and collapsing | |
| // duplicate slashes. Absolute paths keep their leading slash. | |
| function normalizePath(p) { | |
| const isAbs = p.startsWith("/"); | |
| const parts = p.split("/").filter(seg => seg && seg !== "."); | |
| const stack = []; | |
| for (const seg of parts) { | |
| if (seg === "..") stack.pop(); | |
| else stack.push(seg); | |
| } | |
| const joined = stack.join("/"); | |
| if (isAbs) return "/" + joined; | |
| return joined || "."; | |
| } | |
| // Resolves a possibly-relative file/dir name against the current working | |
| // directory into an absolute path, used for every KV file key so that | |
| // directories are real and each file lives in exactly one place. | |
| function resolvePath(name, cwd) { | |
| if (!name) return cwd || "/"; | |
| if (name === "~") return "/"; | |
| if (name === ".") return normalizePath(cwd) || "/"; | |
| const base = normalizePath(cwd) || "/"; | |
| const p = name.startsWith("/") ? name : (base === "/" ? "/" + name : base + "/" + name); | |
| return normalizePath(p); | |
| } | |
| function dirOf(p) { | |
| if (p === "/") return "/"; | |
| const idx = p.lastIndexOf("/"); | |
| return idx <= 0 ? "/" : p.slice(0, idx); | |
| } | |
| function baseOf(p) { | |
| const seg = p.split("/").filter(Boolean); | |
| return seg.length ? seg[seg.length - 1] : "/"; | |
| } | |
| // A directory "exists" if it was mkdir'd explicitly or if it has at least | |
| // one file under it. | |
| function dirExists(dir, fileList, dirIndex) { | |
| if (dir === "/") return true; | |
| if (dirIndex.includes(dir)) return true; | |
| return fileList.some(f => dirOf(f) === dir || f.startsWith(dir + "/")); | |
| } | |
| // Lists the visible entry names inside a directory (files and subdirs). | |
| function lsEntries(dir, fileList, dirIndex) { | |
| const names = new Set(); | |
| for (const f of fileList) { | |
| if (dirOf(f) === dir) names.add(baseOf(f)); | |
| } | |
| for (const d of dirIndex) { | |
| if (dirOf(d) === dir) names.add(baseOf(d)); | |
| } | |
| return [...names].sort(); | |
| } | |
| // Parses command args into flags and positionals, supporting both | |
| // space-separated ("-n 2") and attached ("-n2") value-flag styles. | |
| // Negative numbers are treated as positionals, not flags. | |
| function parseArgs(parts, flagSpec) { | |
| const flags = {}; | |
| const positionals = []; | |
| const bools = flagSpec.booleanFlags || new Set(); | |
| const vals = flagSpec.valueFlags || new Set(); | |
| for (let i = 1; i < parts.length; i++) { | |
| const p = parts[i]; | |
| if (p.startsWith("-") && p.length > 1 && !/^-?\d+$/.test(p)) { | |
| if (vals.has(p)) { | |
| flags[p] = (i + 1 < parts.length) ? parts[i + 1] : ""; | |
| i++; | |
| } else if (p.length > 2 && vals.has(p.slice(0, 2))) { | |
| flags[p.slice(0, 2)] = p.slice(2); | |
| } else if (bools.has(p)) { | |
| flags[p] = true; | |
| } else { | |
| flags[p] = true; // unknown flags are accepted and ignored | |
| } | |
| } else { | |
| positionals.push(p); | |
| } | |
| } | |
| return { flags, positionals }; | |
| } | |
| // Expands a character-set shorthand like "a-z" or "0-9" into an array of | |
| // characters (supports descending ranges like "z-a" and escaped chars). | |
| function expandCharSet(str) { | |
| const out = []; | |
| let i = 0; | |
| while (i < str.length) { | |
| const ch = str[i]; | |
| if (ch === "\\" && i + 1 < str.length) { | |
| out.push(str[i + 1]); | |
| i += 2; | |
| continue; | |
| } | |
| if (i + 2 < str.length && str[i + 1] === "-" && str[i + 2] !== "-") { | |
| const start = str.charCodeAt(i); | |
| const end = str.charCodeAt(i + 2); | |
| const step = start <= end ? 1 : -1; | |
| for (let c = start; step > 0 ? c <= end : c >= end; c += step) out.push(String.fromCharCode(c)); | |
| i += 3; | |
| } else { | |
| out.push(ch); | |
| i += 1; | |
| } | |
| } | |
| return out; | |
| } | |
| // Converts a numeric mode like "0644" into its symbolic -rw-r--r-- form. | |
| function permString(mode) { | |
| const m = parseInt(mode, 8); | |
| let s = ""; | |
| for (let i = 0; i < 9; i++) { | |
| const bit = 0o400 >> i; | |
| s += (m & bit) ? "rwxrwxrwx"[i] : "-"; | |
| } | |
| return s; | |
| } | |
| // Converts a basic (BRE-like) grep pattern into a JS RegExp source string. | |
| // ^ $ . * and [ ... ] keep their regex meaning; backslash-escaped chars and | |
| // metacharacters that are literal in BRE (e.g. ( ) { } + ? |) are escaped so | |
| // they match literally. | |
| function grepPatternToRegex(pattern) { | |
| let out = ""; | |
| for (let i = 0; i < pattern.length; i++) { | |
| const ch = pattern[i]; | |
| if (ch === "\\" && i + 1 < pattern.length) { | |
| out += "\\" + pattern[i + 1]; | |
| i++; | |
| } else if (ch === "[") { | |
| let j = i + 1; | |
| let cls = "["; | |
| if (pattern[j] === "^") { cls += "^"; j++; } | |
| if (pattern[j] === "]") { cls += "\\]"; j++; } | |
| let closed = false; | |
| while (j < pattern.length) { | |
| if (pattern[j] === "]" && cls.length > 1) { closed = true; j++; break; } | |
| if (pattern[j] === "\\" && j + 1 < pattern.length) { cls += pattern[j + 1]; j += 2; continue; } | |
| cls += pattern[j]; | |
| j++; | |
| } | |
| if (!closed) cls += "\\]"; | |
| out += cls; | |
| i = j - 1; | |
| } else if (ch === "^" || ch === "$" || ch === "." || ch === "*") { | |
| out += ch; | |
| } else if ("(){}+?|".includes(ch)) { | |
| out += "\\" + ch; | |
| } else { | |
| out += ch; | |
| } | |
| } | |
| return out; | |
| } | |
| function seedProcesses() { | |
| return JSON.stringify([ | |
| { pid: 1, name: "init" }, | |
| { pid: 42, name: "node" }, | |
| { pid: 87, name: "bash" }, | |
| { pid: 91, name: "ps" } | |
| ]); | |
| } | |
| // Entry point: parses a full command line into logical lines and shell-style | |
| // operators (&&, ||, ; for sequencing, | for piping) and runs each stage | |
| // against the KV-backed simulated environment, wiring piped stdout to the | |
| // next command's stdin. | |
| async function handleBashSimulation(fullCommand, store, logToHistory = true) { | |
| if (!fullCommand || !fullCommand.trim()) return "Error: Empty command."; | |
| const trimmedLine = fullCommand.trim(); | |
| const lines = splitLogicalLines(trimmedLine); | |
| // Log each logical line once per invocation (batched into a single KV | |
| // write). Lines executed from within a .sh script pass logToHistory=false | |
| // so scripts don't spam the KV history table. | |
| if (logToHistory) { | |
| const toLog = lines.filter(l => splitTopLevel(l, ["|"])[0].trim() !== "history"); | |
| if (toLog.length) { | |
| const histList = JSON.parse(await store.get("sys:history") || "[]"); | |
| for (const l of toLog) histList.push(l); | |
| await store.put("sys:history", JSON.stringify(histList.slice(-200))); | |
| } | |
| } | |
| const outputs = []; | |
| for (const line of lines) { | |
| const result = await runChain(line, store); | |
| outputs.push(result.out); | |
| } | |
| return outputs.join("\n"); | |
| } | |
| // Runs a chain of commands joined by &&, ||, or ; with real short-circuit | |
| // semantics based on the exit code of each command. | |
| async function runChain(line, store) { | |
| const chainSegments = splitChain(line); | |
| const chainOutputs = []; | |
| let lastCode = 0; | |
| for (const seg of chainSegments) { | |
| const cmdText = seg.cmd.trim(); | |
| if (!cmdText) continue; | |
| if (seg.op === "&&" && lastCode !== 0) continue; | |
| if (seg.op === "||" && lastCode === 0) continue; | |
| const result = await runPipeline(cmdText, store); | |
| if (result.out !== "") chainOutputs.push(result.out); | |
| lastCode = result.code; | |
| } | |
| return { out: chainOutputs.join("\n"), code: lastCode }; | |
| } | |
| // Runs a pipeline of commands joined by |, threading each stage's stdout | |
| // into the next stage's stdin. | |
| async function runPipeline(cmdText, store) { | |
| const pipeStages = splitTopLevel(cmdText, ["|"]); | |
| let stdin = null; | |
| let stageOutput = ""; | |
| let lastCode = 0; | |
| for (const stage of pipeStages) { | |
| const stageTrim = stage.trim(); | |
| if (!stageTrim) continue; | |
| // Tokenize the stage: resolve single/double quotes and backslash | |
| // escapes, expand $VAR/${VAR} (single-quoted text stays literal). | |
| // Re-fetched per stage so a preceding 'cd' or 'export' in the same | |
| // chain (e.g. "export FOO=bar && echo $FOO") is reflected immediately. | |
| const dirForExpansion = await store.get("sys:cwd") || "/"; | |
| const customEnvForExpansion = JSON.parse(await store.get("sys:env") || "{}"); | |
| const envVarsForExpansion = buildEnvVars(dirForExpansion, customEnvForExpansion); | |
| const stageParts = tokenizeShell(stageTrim, envVarsForExpansion); | |
| const result = await executeSingleCommand(stageParts, store, stdin); | |
| stageOutput = result.out; | |
| lastCode = result.code; | |
| stdin = stageOutput; | |
| } | |
| return { out: stageOutput, code: lastCode }; | |
| } | |
| // Runs a single command stage (no operators) against the KV-backed | |
| // simulated environment. `stdin`, when not null, is the piped-in output | |
| // from the previous stage in the pipeline; commands like grep/sort/uniq/ | |
| // wc/head/tail/cat fall back to it when no explicit file argument is given. | |
| async function executeSingleCommand(parts, store, stdin) { | |
| const primaryCmd = parts[0]; | |
| // Fetch current simulated working directory (default to root '/') | |
| let currentDir = await store.get("sys:cwd") || "/"; | |
| if (!currentDir.startsWith("/")) currentDir = "/" + currentDir; | |
| // Fetch current virtual file/directory index | |
| let fileList = JSON.parse(await store.get("sys:file_index") || "[]"); | |
| let dirIndex = JSON.parse(await store.get("sys:dir_index") || "[]"); | |
| const ok = (out) => ({ out: String(out), code: 0 }); | |
| const fail = (out, code = 1) => ({ out: String(out), code }); | |
| switch (primaryCmd) { | |
| case "python": | |
| case "python3": { | |
| if (parts.length === 1) { | |
| return ok("Python 3.10.12 (simulated mini-interpreter)\nType \"help\" for usage.\nNote: Interactive REPL not supported. Use 'python -c \"code\"' or 'python script.py'."); | |
| } | |
| if (parts[1] === "-c") { | |
| const rawCode = parts.slice(2).join(" ").trim(); | |
| const code = rawCode.replace(/^['"]|['"]$/g, ''); | |
| const out = await runMiniPython(code, store); | |
| return out.startsWith("Traceback") ? fail(out) : ok(out); | |
| } | |
| const scriptTarget = parts[1]; | |
| const pyContent = await store.get(`file:${resolvePath(scriptTarget, currentDir)}`); | |
| if (pyContent === null) { | |
| return fail(`python: can't open file '${scriptTarget}': [Errno 2] No such file or directory`); | |
| } | |
| const out = await runMiniPython(pyContent, store); | |
| return out.startsWith("Traceback") ? fail(out) : ok(out); | |
| } | |
| case "bash": | |
| case "sh": { | |
| // Runs a simple script line-by-line through this same simulated | |
| // command set. No real subshell semantics - just sequential | |
| // execution of each non-blank, non-comment line, using the shared | |
| // handleBashSimulation entry point so &&/||/;/| still work per line. | |
| const scriptTarget = parts[1]; | |
| if (!scriptTarget) return fail(`${primaryCmd}: missing script operand`); | |
| return ok(await runShellScript(scriptTarget, store)); | |
| } | |
| case "pwd": | |
| return ok(currentDir); | |
| case "cd": { | |
| const target = (parts[1] || "/").replace(/^~$/, "/"); | |
| const absDir = resolvePath(target, currentDir); | |
| if (!dirExists(absDir, fileList, dirIndex)) { | |
| return fail(`cd: ${target}: No such file or directory`); | |
| } | |
| await store.put("sys:cwd", absDir); | |
| return ok(""); | |
| } | |
| case "whoami": | |
| return ok("worker"); | |
| case "date": | |
| return ok(new Date().toString()); | |
| case "env": { | |
| const customEnv = JSON.parse(await store.get("sys:env") || "{}"); | |
| const allVars = buildEnvVars(currentDir, customEnv); | |
| return ok(Object.entries(allVars).map(([k, v]) => `${k}=${v}`).join("\n")); | |
| } | |
| case "uname": | |
| if (parts[1] === "-a" || parts[1] === "--all") { | |
| return ok("Linux cloudflare-worker 5.15.0-104-generic #118-Ubuntu SMP Wed Jun 5 10:28:43 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux"); | |
| } | |
| return ok("Linux"); | |
| case "curl": | |
| case "wget": { | |
| const { flags, positionals } = parseArgs(parts, { valueFlags: new Set(["-O", "-o", "-X", "-d", "--data", "-H", "--header"]) }); | |
| const urlTarget = positionals.find(p => p.startsWith("http://") || p.startsWith("https://")); | |
| if (!urlTarget) return fail(`${primaryCmd}: try '${primaryCmd} --help' or provide a valid http/https URL`); | |
| try { | |
| const bodyData = flags["-d"] !== undefined ? flags["-d"] : (flags["--data"] !== undefined ? flags["--data"] : null); | |
| const method = (flags["-X"] || (bodyData !== null ? "POST" : "GET")).toUpperCase(); | |
| const init = { method }; | |
| if (bodyData !== null) { | |
| init.body = bodyData; | |
| init.headers = { "Content-Type": "application/x-www-form-urlencoded" }; | |
| } | |
| const headerStr = flags["-H"] !== undefined ? flags["-H"] : flags["--header"]; | |
| if (headerStr) { | |
| const colonIdx = headerStr.indexOf(":"); | |
| if (colonIdx !== -1) { | |
| init.headers = { ...init.headers, [headerStr.slice(0, colonIdx).trim()]: headerStr.slice(colonIdx + 1).trim() }; | |
| } | |
| } | |
| const resp = await fetch(urlTarget, init); | |
| const text = await resp.text(); | |
| const outFile = flags["-O"] || flags["-o"]; | |
| if (outFile) { | |
| const absFile = resolvePath(outFile, currentDir); | |
| if (!fileList.includes(absFile)) fileList.push(absFile); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| await store.put(`file:${absFile}`, text); | |
| return ok(""); | |
| } | |
| return ok(text); | |
| } catch (err) { | |
| return fail(`${primaryCmd}: (6) Could not resolve host: ${urlTarget}`); | |
| } | |
| } | |
| case "grep": { | |
| const { flags, positionals } = parseArgs(parts, { | |
| booleanFlags: new Set(["-i", "-v", "-n", "-E", "-r"]) | |
| }); | |
| const pattern = positionals[0]; | |
| const fileToSearch = positionals[1]; | |
| let grepContent; | |
| if (fileToSearch) { | |
| grepContent = await store.get(`file:${resolvePath(fileToSearch, currentDir)}`); | |
| if (grepContent === null) return fail(`grep: ${fileToSearch}: No such file or directory`); | |
| } else if (stdin !== null) { | |
| grepContent = stdin; | |
| } else { | |
| return fail("Usage: grep <pattern> <file>"); | |
| } | |
| if (!pattern) return fail("Usage: grep <pattern> <file>"); | |
| const grepRegex = new RegExp(grepPatternToRegex(pattern), flags["-i"] ? "i" : ""); | |
| const allLines = grepContent.split("\n"); | |
| const matched = []; | |
| for (let i = 0; i < allLines.length; i++) { | |
| const isMatch = grepRegex.test(allLines[i]); | |
| if (flags["-v"] ? !isMatch : isMatch) { | |
| matched.push(flags["-n"] ? `${i + 1}:${allLines[i]}` : allLines[i]); | |
| } | |
| } | |
| return ok(matched.join("\n")); | |
| } | |
| case "mkdir": | |
| const dirName = parts[1]; | |
| if (!dirName) return fail("mkdir: missing operand"); | |
| { | |
| const absDir = resolvePath(dirName, currentDir); | |
| if (!dirIndex.includes(absDir)) dirIndex.push(absDir); | |
| await store.put("sys:dir_index", JSON.stringify(dirIndex)); | |
| } | |
| return ok(`Created virtual directory structural path: ${dirName}`); | |
| case "rmdir": { | |
| const rmdirName = parts[1]; | |
| if (!rmdirName) return fail("rmdir: missing operand"); | |
| const absDir = resolvePath(rmdirName, currentDir); | |
| if (absDir === "/") { | |
| return fail(`rmdir: failed to remove '${rmdirName}': Permission denied`); | |
| } | |
| if (!dirExists(absDir, fileList, dirIndex)) { | |
| return fail(`rmdir: failed to remove '${rmdirName}': No such file or directory`); | |
| } | |
| const hasChildren = fileList.some(f => f.startsWith(absDir + "/")) | |
| || dirIndex.some(d => d !== absDir && d.startsWith(absDir + "/")); | |
| if (hasChildren) { | |
| return fail(`rmdir: failed to remove '${rmdirName}': Directory not empty`); | |
| } | |
| dirIndex = dirIndex.filter(d => d !== absDir); | |
| await store.put("sys:dir_index", JSON.stringify(dirIndex)); | |
| return ok(""); | |
| } | |
| case "basename": { | |
| const bnTarget = parts[1]; | |
| if (!bnTarget) return fail("basename: missing operand"); | |
| let base = bnTarget.split("/").filter(Boolean).pop() || "/"; | |
| if (parts[2] && base.endsWith(parts[2])) base = base.slice(0, -parts[2].length); | |
| return ok(base); | |
| } | |
| case "dirname": { | |
| const dnTarget = parts[1]; | |
| if (!dnTarget) return fail("dirname: missing operand"); | |
| const dnParts = dnTarget.split("/").filter(Boolean); | |
| dnParts.pop(); | |
| const prefix = dnTarget.startsWith("/") ? "/" : ""; | |
| return ok(dnParts.length ? prefix + dnParts.join("/") : (dnTarget.startsWith("/") ? "/" : ".")); | |
| } | |
| case "file": { | |
| const fileTarget = parts[1]; | |
| if (!fileTarget) return fail("Usage: file <name>"); | |
| const absFile = resolvePath(fileTarget, currentDir); | |
| if (!fileList.includes(absFile)) return fail(`${fileTarget}: cannot open (No such file or directory)`); | |
| const mode = await store.get(`mode:${absFile}`) || "0644"; | |
| const execBit = (parseInt(mode, 8) & 0o111) !== 0; | |
| if (fileTarget.endsWith(".sh") || execBit) { | |
| return ok(`${fileTarget}: Bourne-Again shell script, ASCII text executable`); | |
| } | |
| const fContent = await store.get(`file:${absFile}`) || ""; | |
| return ok(`${fileTarget}: ${fContent === "" ? "empty" : "ASCII text"}`); | |
| } | |
| case "stat": { | |
| const statTarget = parts[1]; | |
| if (!statTarget) return fail("Usage: stat <name>"); | |
| const absFile = resolvePath(statTarget, currentDir); | |
| const statContent = await store.get(`file:${absFile}`); | |
| if (statContent === null) return fail(`stat: cannot statx '${statTarget}': No such file or directory`); | |
| const mode = await store.get(`mode:${absFile}`) || "0644"; | |
| return ok(` File: ${statTarget}\n Size: ${statContent.length}\t\tBlocks: 8\t\tIO Block: 4096 regular file\nAccess: (${mode}/${permString(mode)}) Uid: ( 1000/ worker) Gid: ( 1000/ worker)`); | |
| } | |
| case "nl": { | |
| const { positionals } = parseArgs(parts, {}); | |
| const nlFile = positionals[0]; | |
| let nlContent; | |
| if (nlFile) { | |
| nlContent = await store.get(`file:${resolvePath(nlFile, currentDir)}`); | |
| if (nlContent === null) return fail(`nl: ${nlFile}: No such file or directory`); | |
| } else if (stdin !== null) { | |
| nlContent = stdin; | |
| } else { | |
| return ok(""); | |
| } | |
| return ok(nlContent.split("\n").map((l, i) => `${String(i + 1).padStart(6)}\t${l}`).join("\n")); | |
| } | |
| case "cut": { | |
| const { flags, positionals } = parseArgs(parts, { valueFlags: new Set(["-d", "-f"]) }); | |
| const delim = flags["-d"] !== undefined ? flags["-d"] : "\t"; | |
| const fields = flags["-f"] !== undefined ? flags["-f"].split(",").map(n => parseInt(n, 10)) : null; | |
| const cutFile = positionals[0] || null; | |
| let cutContent; | |
| if (cutFile) { | |
| cutContent = await store.get(`file:${resolvePath(cutFile, currentDir)}`); | |
| if (cutContent === null) return fail(`cut: ${cutFile}: No such file or directory`); | |
| } else if (stdin !== null) { | |
| cutContent = stdin; | |
| } else { | |
| return fail("Usage: cut -d<delim> -f<fields> <file>"); | |
| } | |
| if (!fields) return fail("cut: you must specify -f"); | |
| return ok(cutContent.split("\n").map(line => { | |
| const cols = line.split(delim); | |
| return fields.map(n => cols[n - 1] ?? "").join(delim); | |
| }).join("\n")); | |
| } | |
| case "tr": { | |
| const [, set1, set2] = parts; | |
| if (!set1) return fail("Usage: tr <set1> <set2>"); | |
| const trInput = stdin !== null ? stdin : ""; | |
| if (!set2) return ok(trInput); | |
| const from = expandCharSet(set1); | |
| const to = expandCharSet(set2); | |
| let result = ""; | |
| for (const ch of trInput) { | |
| const idx = from.indexOf(ch); | |
| result += idx !== -1 ? (to[idx] !== undefined ? to[idx] : to[to.length - 1]) : ch; | |
| } | |
| return ok(result); | |
| } | |
| case "seq": { | |
| let start = 1, stop, step = 1; | |
| if (parts.length === 2) stop = parseInt(parts[1], 10); | |
| else if (parts.length === 3) { start = parseInt(parts[1], 10); stop = parseInt(parts[2], 10); } | |
| else if (parts.length >= 4) { start = parseInt(parts[1], 10); step = parseInt(parts[2], 10); stop = parseInt(parts[3], 10); } | |
| else return fail("Usage: seq [start] [step] stop"); | |
| const seqOut = []; | |
| if (step > 0) for (let n = start; n <= stop; n += step) seqOut.push(n); | |
| else if (step < 0) for (let n = start; n >= stop; n += step) seqOut.push(n); | |
| return ok(seqOut.join("\n")); | |
| } | |
| case "yes": { | |
| const yesText = parts.length > 1 ? parts.slice(1).join(" ") : "y"; | |
| // Real `yes` repeats forever; capped here since there's no pipe-aware | |
| // early termination (e.g. `yes | head -3`) in this simulation. | |
| return ok(Array(20).fill(yesText).join("\n") + "\n... (yes: capped at 20 lines in this simulated environment)"); | |
| } | |
| case "true": | |
| return ok(""); | |
| case "false": | |
| return fail("", 1); | |
| case "tee": { | |
| const teeFile = parts[1]; | |
| const teeInput = stdin !== null ? stdin : ""; | |
| if (teeFile) { | |
| const absFile = resolvePath(teeFile, currentDir); | |
| if (!fileList.includes(absFile)) fileList.push(absFile); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| await store.put(`file:${absFile}`, teeInput); | |
| } | |
| return ok(teeInput); | |
| } | |
| case "diff": { | |
| const [, diffA, diffB] = parts; | |
| if (!diffA || !diffB) return fail("Usage: diff <file1> <file2>"); | |
| const contentA = await store.get(`file:${resolvePath(diffA, currentDir)}`); | |
| const contentB = await store.get(`file:${resolvePath(diffB, currentDir)}`); | |
| if (contentA === null) return fail(`diff: ${diffA}: No such file or directory`); | |
| if (contentB === null) return fail(`diff: ${diffB}: No such file or directory`); | |
| if (contentA === contentB) return ok(""); | |
| const linesA = contentA.split("\n"); | |
| const linesB = contentB.split("\n"); | |
| const diffLines = []; | |
| const maxLen = Math.max(linesA.length, linesB.length); | |
| for (let i = 0; i < maxLen; i++) { | |
| if (linesA[i] !== linesB[i]) { | |
| if (linesA[i] !== undefined) diffLines.push(`< ${linesA[i]}`); | |
| if (linesB[i] !== undefined) diffLines.push(`> ${linesB[i]}`); | |
| } | |
| } | |
| return ok(diffLines.join("\n")); | |
| } | |
| case "ln": { | |
| const [, lnSrc, lnDest] = parts; | |
| if (!lnSrc || !lnDest) return fail("Usage: ln [-s] <source> <dest>"); | |
| const absSrc = resolvePath(lnSrc, currentDir); | |
| const absDest = resolvePath(lnDest, currentDir); | |
| const lnSrcContent = await store.get(`file:${absSrc}`); | |
| if (lnSrcContent === null) return fail(`ln: failed to access '${lnSrc}': No such file or directory`); | |
| // No real symlinks in this KV store - links behave like copies. | |
| await store.put(`file:${absDest}`, lnSrcContent); | |
| const srcMode = await store.get(`mode:${absSrc}`); | |
| if (srcMode !== null) await store.put(`mode:${absDest}`, srcMode); | |
| if (!fileList.includes(absDest)) fileList.push(absDest); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| return ok(""); | |
| } | |
| case "help": { | |
| const helpTopics = { | |
| "python": "python [-c \"code\" | file.py] - run lightweight Python code, calculate pi, print outputs, use math functions, and execute simple scripts.", | |
| "python3": "python3 [-c \"code\" | file.py] - alias for python", | |
| "cd": "cd [dir] - change the simulated working directory", | |
| "pwd": "pwd - print the simulated working directory", | |
| "ls": "ls [-l] [dir] - list files in the current (or given) directory", | |
| "cat": "cat <file> - print a file's contents (or stdin if piped)", | |
| "echo": "echo [text] [> file] - print text, or write it to a file", | |
| "touch": "touch <file> - create an empty file", | |
| "rm": "rm <file> - delete a file", | |
| "mkdir": "mkdir <dir> - create a directory", | |
| "rmdir": "rmdir <dir> - remove an empty directory", | |
| "mv": "mv <src> <dest> - rename/move a file", | |
| "cp": "cp <src> <dest> - copy a file", | |
| "ln": "ln <src> <dest> - link a file (behaves like a copy here)", | |
| "grep": "grep [-i] [-v] [-n] <pattern> <file> - filter lines containing pattern", | |
| "find": "find [dir] -name <pattern> - list files, optionally filtered by name glob", | |
| "cut": "cut -d<delim> -f<fields> <file> - extract columns from lines", | |
| "tr": "tr <set1> <set2> - translate characters (stdin only)", | |
| "sort": "sort [-r] <file> - sort lines", | |
| "uniq": "uniq <file> - collapse adjacent duplicate lines", | |
| "nl": "nl <file> - number lines", | |
| "wc": "wc [-l|-w|-c] <file> - count lines/words/characters", | |
| "head": "head [-n <N>] <file> - print the first N lines (default 10)", | |
| "tail": "tail [-n <N>] <file> - print the last N lines (default 10)", | |
| "tee": "tee <file> - write stdin to a file and echo it back", | |
| "diff": "diff <file1> <file2> - show differing lines", | |
| "stat": "stat <file> - show simulated file metadata", | |
| "file": "file <file> - guess a file's type", | |
| "basename": "basename <path> [suffix] - strip directory (and suffix) from a path", | |
| "dirname": "dirname <path> - print a path's directory portion", | |
| "bash": "bash <script.sh> - run a simple script line by line (also: sh <script.sh>, ./script.sh)", | |
| "curl": "curl <url> - fetch a URL and print the response body", | |
| "wget": "wget [-O <file>] <url> - fetch a URL (optionally saving to a file)", | |
| "export": "export VAR=value - set an environment variable", | |
| "env": "env - list all environment variables", | |
| "printenv": "printenv [VAR] - print all or one environment variable", | |
| "history": "history - list previously run commands", | |
| "which": "which <cmd> - show a command's simulated path", | |
| "type": "type <cmd> - say whether a command is a builtin or a binary", | |
| "seq": "seq [start] [step] stop - print a sequence of numbers", | |
| "yes": "yes [text] - repeat text (capped at 20 lines here)", | |
| "true": "true - do nothing, successfully", | |
| "false": "false - do nothing, unsuccessfully", | |
| "chmod": "chmod <mode> <file> - change a file's permissions (octal or symbolic like +x)", | |
| "sleep": "sleep <seconds> - no-op in this environment", | |
| "kill": "kill <pid> - terminate a process listed by ps/top", | |
| "uname": "uname [-a] - print simulated system info", | |
| "whoami": "whoami - print the simulated username", | |
| "id": "id - print simulated uid/gid info", | |
| "groups": "groups - print simulated group membership", | |
| "hostname": "hostname - print the simulated hostname", | |
| "hostnamectl": "hostnamectl - print simulated extended host info", | |
| "date": "date - print the current date/time", | |
| "uptime": "uptime - print simulated uptime and load averages", | |
| "free": "free - print simulated memory usage", | |
| "df": "df - print simulated disk usage", | |
| "du": "du [file] - print simulated disk usage of a path", | |
| "nproc": "nproc - print simulated CPU count", | |
| "arch": "arch - print simulated CPU architecture", | |
| "ps": "ps - print a simulated process list", | |
| "top": "top - print a simulated process snapshot", | |
| }; | |
| const helpTarget = parts[1]; | |
| if (helpTarget) { | |
| return ok(helpTopics[helpTarget] ? helpTopics[helpTarget] : `help: no help topic for '${helpTarget}'`); | |
| } | |
| return ok([ | |
| "Simulated bash environment - commands run against a persistent KV-backed virtual filesystem, not a real shell.", | |
| "Run 'help <command>' for details on a specific command.", | |
| "", | |
| "Python: python python3 (e.g. python -c \"import math; print(math.pi)\")", | |
| "Files: ls cat echo touch rm mkdir rmdir mv cp ln find", | |
| "Text: grep cut tr sort uniq nl wc head tail tee diff stat file basename dirname", | |
| "Scripts: bash <file.sh> / sh <file.sh> / ./file.sh (runs simple scripts line by line)", | |
| "Network: curl wget", | |
| "Environment: export env printenv history which type", | |
| "Misc: seq yes true false chmod sleep kill", | |
| "System info: uname whoami id groups hostname hostnamectl date uptime free df du nproc arch ps top", | |
| "", | |
| "Operators supported between commands: && || ; |", | |
| "Note: this is a simulated, KV-backed environment with a virtual filesystem - commands like && and || honor exit codes; see individual commands' quirks via 'help <command>'." | |
| ].join("\n")); | |
| } | |
| case "type": { | |
| const typeTarget = parts[1]; | |
| if (!typeTarget) return fail("Usage: type <command>"); | |
| const builtins = ["cd", "pwd", "echo", "export", "history", "printenv", "true", "false", "help"]; | |
| const knownBins = ["python","python3","bash","sh","ls","cat","grep","touch","rm","mkdir","rmdir","curl","wget","uname","whoami","env","date","id","hostname","hostnamectl","ps","top","kill","df","du","free","nproc","arch","which","type","head","tail","wc","sort","uniq","find","mv","cp","ln","cut","tr","seq","yes","nl","tee","diff","stat","file","basename","dirname","chmod","sleep"]; | |
| if (builtins.includes(typeTarget)) return ok(`${typeTarget} is a shell builtin`); | |
| if (knownBins.includes(typeTarget)) return ok(`${typeTarget} is /usr/bin/${typeTarget}`); | |
| return fail(`bash: type: ${typeTarget}: not found`); | |
| } | |
| case "touch": { | |
| const newFile = parts[1]; | |
| if (!newFile) return fail("touch: missing file operand"); | |
| const absFile = resolvePath(newFile, currentDir); | |
| if (!fileList.includes(absFile)) { | |
| fileList.push(absFile); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| } | |
| await store.put(`file:${absFile}`, ""); | |
| return ok(""); | |
| } | |
| case "echo": { | |
| const appendIndex = parts.indexOf(">>"); | |
| const targetRedirectIndex = appendIndex !== -1 ? appendIndex : parts.indexOf(">"); | |
| if (targetRedirectIndex !== -1 && parts[targetRedirectIndex + 1]) { | |
| const fileName = parts[targetRedirectIndex + 1]; | |
| const contentStr = parts.slice(1, targetRedirectIndex).join(" "); | |
| const absFile = resolvePath(fileName, currentDir); | |
| if (!fileList.includes(absFile)) { | |
| fileList.push(absFile); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| } | |
| const existing = await store.get(`file:${absFile}`); | |
| const newContent = appendIndex !== -1 ? (existing || "") + contentStr + "\n" : contentStr + "\n"; | |
| await store.put(`file:${absFile}`, newContent); | |
| return ok(""); | |
| } | |
| return ok(parts.slice(1).join(" ") + "\n"); | |
| } | |
| case "ls": { | |
| const { flags, positionals } = parseArgs(parts, { booleanFlags: new Set(["-l", "-a"]) }); | |
| const lsArg = positionals[0]; | |
| const lsDir = lsArg ? resolvePath(lsArg, currentDir) : currentDir; | |
| if (!dirExists(lsDir, fileList, dirIndex)) { | |
| return fail(`ls: cannot access '${lsArg}': No such file or directory`); | |
| } | |
| const entries = lsEntries(lsDir, fileList, dirIndex); | |
| if (flags["-l"]) { | |
| const rows = []; | |
| for (const ent of entries) { | |
| const entPath = (lsDir === "/" ? "/" + ent : lsDir + "/" + ent); | |
| const isDir = dirIndex.includes(entPath); | |
| const content = await store.get(`file:${entPath}`); | |
| const mode = await store.get(`mode:${entPath}`) || (isDir ? "0755" : "0644"); | |
| const size = isDir ? 4096 : (content !== null ? content.length : 0); | |
| rows.push(`${isDir ? "d" : "-"}${permString(mode)} 1 worker worker ${size} ${ent}`); | |
| } | |
| return ok(rows.join("\n")); | |
| } | |
| return ok(entries.join(" ")); | |
| } | |
| case "cat": { | |
| const readFileTargets = parts.slice(1); | |
| if (readFileTargets.length === 0) { | |
| return ok(stdin !== null ? stdin : "cat: missing file operand"); | |
| } | |
| const catChunks = []; | |
| for (const target of readFileTargets) { | |
| const absFile = resolvePath(target, currentDir); | |
| const fileContent = await store.get(`file:${absFile}`); | |
| if (fileContent === null) return fail(`cat: ${target}: No such file or directory`); | |
| catChunks.push(fileContent); | |
| } | |
| return ok(catChunks.join("")); | |
| } | |
| case "rm": { | |
| const rmFileTarget = parts[1]; | |
| if (!rmFileTarget) return fail("rm: missing operand"); | |
| const absFile = resolvePath(rmFileTarget, currentDir); | |
| if (!fileList.includes(absFile)) { | |
| return fail(`rm: cannot remove '${rmFileTarget}': No such file or directory`); | |
| } | |
| fileList = fileList.filter(f => f !== absFile); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| await store.delete(`file:${absFile}`); | |
| await store.delete(`mode:${absFile}`); | |
| return ok(""); | |
| } | |
| case "clear": | |
| return ok(""); | |
| case "hostname": | |
| return ok("cloudflare-worker"); | |
| case "id": | |
| return ok("uid=1000(worker) gid=1000(worker) groups=1000(worker),27(sudo)"); | |
| case "groups": | |
| return ok("worker sudo"); | |
| case "hostnamectl": | |
| return ok(" Static hostname: cloudflare-worker\n Icon name: computer-vm\n Chassis: vm\n Operating System: Ubuntu 22.04.4 LTS\n Kernel: Linux 5.15.0-104-generic\n Architecture: x86-64"); | |
| case "uptime": | |
| return ok(`${new Date().toTimeString().split(' ')[0]} up 4 days, 2:17, 1 user, load average: 0.08, 0.12, 0.09`); | |
| case "free": | |
| return ok(" total used free shared buff/cache available\nMem: 8165052 1214392 4523108 12440 2427552 6710880\nSwap: 2097148 0 2097148"); | |
| case "df": | |
| return ok("Filesystem 1K-blocks Used Available Use% Mounted on\noverlay 61255492 9123456 48912345 16% /\ntmpfs 65536 0 65536 0% /dev\n/dev/sda1 61255492 9123456 48912345 16% /etc/hosts"); | |
| case "du": { | |
| const duTarget = parts[1] ? resolvePath(parts[1], currentDir) : currentDir; | |
| return ok(`4.0K\t${duTarget}`); | |
| } | |
| case "nproc": | |
| return ok("2"); | |
| case "arch": | |
| return ok("x86_64"); | |
| case "ps": { | |
| const procs = JSON.parse(await store.get("sys:processes") || seedProcesses()); | |
| return ok(" PID TTY TIME CMD\n" + procs.map(p => `${String(p.pid).padStart(5)} ? 00:00:00 ${p.name}`).join("\n")); | |
| } | |
| case "top": { | |
| const procs = JSON.parse(await store.get("sys:processes") || seedProcesses()); | |
| return ok("top - simplified snapshot\nPID USER PR NI VIRT RES %CPU %MEM COMMAND\n" + procs.map(p => `${p.pid} worker 20 0 102400 7568 0.0 0.1 ${p.name}`).join("\n")); | |
| } | |
| case "kill": { | |
| const pid = parts[1]; | |
| if (!pid) return fail("Usage: kill <pid>"); | |
| const procs = JSON.parse(await store.get("sys:processes") || seedProcesses()); | |
| const idx = procs.findIndex(p => String(p.pid) === pid); | |
| if (idx === -1) return fail(`bash: kill: (${pid}) - No such process`); | |
| procs.splice(idx, 1); | |
| await store.put("sys:processes", JSON.stringify(procs)); | |
| return ok(""); | |
| } | |
| case "which": { | |
| const whichTarget = parts[1]; | |
| if (!whichTarget) return fail("Usage: which <command>"); | |
| const knownBins = ["python","python3","bash","sh","ls","cd","pwd","cat","echo","grep","touch","rm","mkdir","rmdir","curl","wget","uname","whoami","env","date","id","hostname","ps","df","du","free","top","which","type","head","tail","wc","sort","uniq","find","mv","cp","ln","cut","tr","seq","yes","nl","tee","diff","stat","file","basename","dirname","history","printenv","export","chmod","sleep"]; | |
| return ok(knownBins.includes(whichTarget) ? `/usr/bin/${whichTarget}` : `${whichTarget} not found`); | |
| } | |
| case "printenv": { | |
| const customEnv = JSON.parse(await store.get("sys:env") || "{}"); | |
| const allVars = buildEnvVars(currentDir, customEnv); | |
| if (parts[1]) { | |
| return ok(allVars[parts[1]] !== undefined ? allVars[parts[1]] : ""); | |
| } | |
| return ok(Object.entries(allVars).map(([k, v]) => `${k}=${v}`).join("\n")); | |
| } | |
| case "export": { | |
| const assignments = parts.slice(1); | |
| if (assignments.length === 0) return ok(""); | |
| const customEnv = JSON.parse(await store.get("sys:env") || "{}"); | |
| for (const assignment of assignments) { | |
| const eqIdx = assignment.indexOf("="); | |
| if (eqIdx === -1) continue; // "export SOMEVAR" with no value: no-op in this simulation | |
| const name = assignment.slice(0, eqIdx); | |
| const value = assignment.slice(eqIdx + 1).replace(/^['"]|['"]$/g, ''); | |
| customEnv[name] = value; | |
| } | |
| await store.put("sys:env", JSON.stringify(customEnv)); | |
| return ok(""); | |
| } | |
| case "chmod": { | |
| const modeArg = parts[1]; | |
| const chmodFile = parts[2]; | |
| if (!modeArg || !chmodFile) return fail("Usage: chmod <mode> <file>"); | |
| const absFile = resolvePath(chmodFile, currentDir); | |
| if (!fileList.includes(absFile)) return fail(`chmod: cannot access '${chmodFile}': No such file or directory`); | |
| let mode = await store.get(`mode:${absFile}`) || "0644"; | |
| if (/^[0-7]{3,4}$/.test(modeArg)) { | |
| mode = modeArg.padStart(4, "0"); | |
| } else { | |
| // Symbolic modes like +x, -x, +r, -rwx (optionally comma-separated). | |
| let m = parseInt(mode, 8); | |
| const bits = { r: 0o444, w: 0o222, x: 0o111 }; | |
| for (const part of modeArg.split(",")) { | |
| const plusIdx = part.indexOf("+"); | |
| const minusIdx = part.indexOf("-"); | |
| if (plusIdx === -1 && minusIdx === -1) continue; | |
| const isPlus = plusIdx !== -1; | |
| const opIdx = isPlus ? plusIdx : minusIdx; | |
| const who = opIdx > 0 ? part.slice(0, opIdx) : "a"; | |
| const perms = part.slice(opIdx + 1); | |
| let mask = 0; | |
| for (const ch of perms) { | |
| if (!bits[ch]) continue; | |
| let m2 = bits[ch]; | |
| if (who.includes("u")) m2 &= 0o700; | |
| else if (who.includes("g")) m2 &= 0o070; | |
| else if (who.includes("o")) m2 &= 0o007; | |
| mask |= m2; | |
| } | |
| if (isPlus) m |= mask; else m &= ~mask; | |
| } | |
| mode = m.toString(8).padStart(4, "0"); | |
| } | |
| await store.put(`mode:${absFile}`, mode); | |
| return ok(`mode of '${chmodFile}' changed`); | |
| } | |
| case "sleep": | |
| return ok(""); | |
| case "history": { | |
| const histList = JSON.parse(await store.get("sys:history") || "[]"); | |
| return ok(histList.map((c, i) => `${i + 1} ${c}`).join("\n")); | |
| } | |
| case "head": { | |
| const { flags, positionals } = parseArgs(parts, { valueFlags: new Set(["-n"]) }); | |
| const n = flags["-n"] !== undefined ? parseInt(flags["-n"], 10) : 10; | |
| const headFile = positionals[0] || null; | |
| let headContent; | |
| if (headFile) { | |
| headContent = await store.get(`file:${resolvePath(headFile, currentDir)}`); | |
| if (headContent === null) return fail(`head: cannot open '${headFile}' for reading: No such file or directory`); | |
| } else if (stdin !== null) { | |
| headContent = stdin; | |
| } else { | |
| return ok(""); | |
| } | |
| return ok(headContent.split("\n").slice(0, n).join("\n")); | |
| } | |
| case "tail": { | |
| const { flags, positionals } = parseArgs(parts, { valueFlags: new Set(["-n"]) }); | |
| const n = flags["-n"] !== undefined ? parseInt(flags["-n"], 10) : 10; | |
| const tailFile = positionals[0] || null; | |
| let tailContent; | |
| if (tailFile) { | |
| tailContent = await store.get(`file:${resolvePath(tailFile, currentDir)}`); | |
| if (tailContent === null) return fail(`tail: cannot open '${tailFile}' for reading: No such file or directory`); | |
| } else if (stdin !== null) { | |
| tailContent = stdin; | |
| } else { | |
| return ok(""); | |
| } | |
| const lines = tailContent.split("\n"); | |
| return ok(lines.slice(Math.max(lines.length - n, 0)).join("\n")); | |
| } | |
| case "wc": { | |
| const { flags, positionals } = parseArgs(parts, { booleanFlags: new Set(["-l", "-w", "-c"]) }); | |
| const wcFile = positionals[0] || null; | |
| let wcContent; | |
| if (wcFile) { | |
| wcContent = await store.get(`file:${resolvePath(wcFile, currentDir)}`); | |
| if (wcContent === null) return fail(`wc: ${wcFile}: No such file or directory`); | |
| } else if (stdin !== null) { | |
| wcContent = stdin; | |
| } else { | |
| return ok("0 0 0"); | |
| } | |
| const lineCount = (wcContent.match(/\n/g) || []).length; | |
| const wordCount = wcContent.split(/\s+/).filter(Boolean).length; | |
| const charCount = wcContent.length; | |
| const suffix = wcFile ? " " + wcFile : ""; | |
| const counts = []; | |
| if (flags["-l"]) counts.push(lineCount); | |
| if (flags["-w"]) counts.push(wordCount); | |
| if (flags["-c"]) counts.push(charCount); | |
| if (counts.length === 0) counts.push(lineCount, wordCount, charCount); | |
| return ok(counts.join(" ") + suffix); | |
| } | |
| case "sort": { | |
| const { flags, positionals } = parseArgs(parts, { booleanFlags: new Set(["-r", "-n", "-u"]) }); | |
| const sortFile = positionals[0] || null; | |
| let sortContent; | |
| if (sortFile) { | |
| sortContent = await store.get(`file:${resolvePath(sortFile, currentDir)}`); | |
| if (sortContent === null) return fail(`sort: cannot read: ${sortFile}: No such file or directory`); | |
| } else if (stdin !== null) { | |
| sortContent = stdin; | |
| } else { | |
| return ok(""); | |
| } | |
| let lines = sortContent.split("\n"); | |
| if (flags["-n"]) { | |
| lines = lines.sort((a, b) => ((parseFloat(a) || 0) - (parseFloat(b) || 0))); | |
| } else { | |
| lines = lines.sort(); | |
| } | |
| if (flags["-u"]) lines = [...new Set(lines)]; | |
| if (flags["-r"]) lines = lines.reverse(); | |
| return ok(lines.join("\n")); | |
| } | |
| case "uniq": { | |
| const { flags, positionals } = parseArgs(parts, { booleanFlags: new Set(["-c"]) }); | |
| const uniqFile = positionals[0] || null; | |
| let uniqContent; | |
| if (uniqFile) { | |
| uniqContent = await store.get(`file:${resolvePath(uniqFile, currentDir)}`); | |
| if (uniqContent === null) return fail(`uniq: cannot read: ${uniqFile}: No such file or directory`); | |
| } else if (stdin !== null) { | |
| uniqContent = stdin; | |
| } else { | |
| return ok(""); | |
| } | |
| if (flags["-c"]) { | |
| const counted = []; | |
| for (const line of uniqContent.split("\n")) { | |
| const last = counted[counted.length - 1]; | |
| if (last && last.line === line) last.count++; | |
| else counted.push({ line, count: 1 }); | |
| } | |
| return ok(counted.map(s => `${String(s.count).padStart(4)} ${s.line}`).join("\n")); | |
| } | |
| const seen = []; | |
| for (const line of uniqContent.split("\n")) { | |
| if (seen[seen.length - 1] !== line) seen.push(line); | |
| } | |
| return ok(seen.join("\n")); | |
| } | |
| case "find": { | |
| const { flags, positionals } = parseArgs(parts, { valueFlags: new Set(["-name", "-type"]) }); | |
| const pattern = flags["-name"]; | |
| const typeFlag = flags["-type"]; | |
| const baseArg = positionals[0] || "."; | |
| let results; | |
| if (typeFlag === "d") { | |
| results = dirIndex.slice(); | |
| if (baseArg !== "." && baseArg !== "/") { | |
| const basePath = resolvePath(baseArg, currentDir); | |
| results = results.filter(d => d === basePath || d.startsWith(basePath + "/")); | |
| } | |
| } else { | |
| results = fileList.slice(); | |
| if (baseArg !== "." && baseArg !== "/") { | |
| const basePath = resolvePath(baseArg, currentDir); | |
| results = results.filter(f => f === basePath || f.startsWith(basePath + "/")); | |
| } | |
| } | |
| if (pattern) { | |
| const escaped = pattern | |
| .replace(/[.+^${}()|[\]\\]/g, "\\$&") | |
| .replace(/\*/g, ".*") | |
| .replace(/\?/g, "."); | |
| const re = new RegExp("^" + escaped + "$"); | |
| results = results.filter(f => re.test(baseOf(f))); | |
| } | |
| if (typeFlag === "f") { | |
| results = results.filter(f => !dirIndex.includes(f)); | |
| } | |
| return ok(results.length ? results.map(f => "./" + f.replace(/^\//, "")).join("\n") : ""); | |
| } | |
| case "mv": { | |
| const [, src, dest] = parts; | |
| if (!src || !dest) return fail("Usage: mv <source> <dest>"); | |
| const absSrc = resolvePath(src, currentDir); | |
| const absDest = resolvePath(dest, currentDir); | |
| const srcContent = await store.get(`file:${absSrc}`); | |
| if (srcContent === null) return fail(`mv: cannot stat '${src}': No such file or directory`); | |
| await store.put(`file:${absDest}`, srcContent); | |
| await store.delete(`file:${absSrc}`); | |
| const srcMode = await store.get(`mode:${absSrc}`); | |
| if (srcMode !== null) { | |
| await store.put(`mode:${absDest}`, srcMode); | |
| await store.delete(`mode:${absSrc}`); | |
| } | |
| fileList = fileList.filter(f => f !== absSrc); | |
| if (!fileList.includes(absDest)) fileList.push(absDest); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| return ok(""); | |
| } | |
| case "cp": { | |
| const [, csrc, cdest] = parts; | |
| if (!csrc || !cdest) return fail("Usage: cp <source> <dest>"); | |
| const absSrc = resolvePath(csrc, currentDir); | |
| const absDest = resolvePath(cdest, currentDir); | |
| const csrcContent = await store.get(`file:${absSrc}`); | |
| if (csrcContent === null) return fail(`cp: cannot stat '${csrc}': No such file or directory`); | |
| await store.put(`file:${absDest}`, csrcContent); | |
| const srcMode = await store.get(`mode:${absSrc}`); | |
| if (srcMode !== null) await store.put(`mode:${absDest}`, srcMode); | |
| if (!fileList.includes(absDest)) fileList.push(absDest); | |
| await store.put("sys:file_index", JSON.stringify(fileList)); | |
| return ok(""); | |
| } | |
| default: | |
| if (primaryCmd.endsWith(".sh")) { | |
| // Direct invocation, e.g. "./deploy.sh" or "deploy.sh" | |
| const scriptTarget = primaryCmd.replace(/^\.\//, ""); | |
| return ok(await runShellScript(scriptTarget, store)); | |
| } | |
| if (primaryCmd.endsWith(".py")) { | |
| const pyTarget = primaryCmd.replace(/^\.\//, ""); | |
| const out = await runMiniPython(await store.get(`file:${resolvePath(pyTarget, currentDir)}`) || "", store); | |
| return out.startsWith("Traceback") ? fail(out) : ok(out); | |
| } | |
| return fail(`${primaryCmd}: command not found (Worker Environment limited execution layer)`, 127); | |
| } | |
| } | |
| // Reads a virtual file and runs each non-blank, non-comment line through | |
| // handleBashSimulation, in order, joining their outputs. Only supports the | |
| // simplified command set already implemented above - anything requiring a | |
| // real shell (control flow, variables assigned inline without `export`, | |
| // functions, etc.) will not behave like a real interpreter. Script lines are | |
| // executed with logToHistory=false so they don't spam the KV history table. | |
| async function runShellScript(scriptTarget, store) { | |
| const absScript = resolvePath(scriptTarget, await store.get("sys:cwd") || "/"); | |
| const scriptContent = await store.get(`file:${absScript}`); | |
| if (scriptContent === null) { | |
| return `bash: ${scriptTarget}: No such file or directory`; | |
| } | |
| const lines = scriptContent.split("\n"); | |
| const outputs = []; | |
| for (const rawLine of lines) { | |
| const line = rawLine.trim(); | |
| if (!line || line.startsWith("#")) continue; // skip blanks, comments, shebang | |
| const out = await handleBashSimulation(line, store, false); | |
| if (out) outputs.push(out); | |
| } | |
| return outputs.join("\n"); | |
| } | |
| // --- LIGHTWEIGHT MINI PYTHON ENGINE --- | |
| async function runMiniPython(code, store) { | |
| if (!code || !code.trim()) return ""; | |
| const I = { | |
| outputs: [], | |
| iterBudget: 500000, | |
| lastExprValue: undefined, | |
| mathModule: null, | |
| }; | |
| try { | |
| const tokens = pyTokenize(code); | |
| const ast = pyParse(tokens); | |
| const builtinEnv = pyMakeEnv(null); | |
| const builtins = pyBuiltins(I); | |
| for (const k in builtins) builtinEnv.vars.set(k, builtins[k]); | |
| const globalEnv = pyMakeEnv(builtinEnv); | |
| I.globalEnv = globalEnv; | |
| pyExecBlock(ast.body, globalEnv, I); | |
| if (I.lastExprValue !== undefined && I.outputs.length === 0) { | |
| I.outputs.push(pyStr(I.lastExprValue)); | |
| } | |
| return I.outputs.join("\n"); | |
| } catch (err) { | |
| let message = err && err.message ? err.message : String(err); | |
| if (err && err.__pySignal === "return") message = "'return' outside function"; | |
| if (err && err.__pySignal === "break") message = "'break' outside loop"; | |
| if (err && err.__pySignal === "continue") message = "'continue' not properly in loop"; | |
| const line = err && err.pyLine ? ` File "<stdin>", line ${err.pyLine}\n` : ""; | |
| return `Traceback (most recent call last):\n${line}PythonError: ${message}`; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // MINI PYTHON INTERPRETER | |
| // A small tree-walking interpreter for a practical subset of Python. It runs | |
| // without eval()/new Function() (both are blocked by CSP in Cloudflare | |
| // Workers / Deno Deploy / Supabase Edge). Supports: variables & tuple | |
| // unpacking, arithmetic (incl. // % **), comparisons (chained, in/is), | |
| // and/or/not, if/elif/else, while, for-in (range/lists/strings/dicts), | |
| // def (defaults, *args, **kwargs), return/break/continue/pass, import math / | |
| // from math import x, lists, dicts, string/list/dict methods, f-strings, | |
| // slicing, and common builtins (print range len sum min max abs pow round | |
| // int float str list dict bool sorted reversed enumerate zip type input). | |
| // --------------------------------------------------------------------------- | |
| function pyError(message, line) { | |
| const e = new Error(message); | |
| e.pyLine = line; | |
| e.__pyError = true; | |
| return e; | |
| } | |
| function pySignal(kind, value) { | |
| const e = new Error(kind); | |
| e.__pySignal = kind; | |
| e.__pyValue = value; | |
| return e; | |
| } | |
| function pyTokenize(src) { | |
| const tokens = []; | |
| const lines = src.split("\n"); | |
| const indentStack = [0]; | |
| const push = (type, value, line) => tokens.push({ type, value, line }); | |
| for (let li = 0; li < lines.length; li++) { | |
| let line = lines[li]; | |
| if (line.endsWith("\r")) line = line.slice(0, -1); | |
| let indent = 0; | |
| while (indent < line.length && (line[indent] === " " || line[indent] === "\t")) indent++; | |
| const content = line.slice(indent); | |
| const trimmed = content.trim(); | |
| if (!trimmed || trimmed.startsWith("#")) continue; | |
| const lineNo = li + 1; | |
| const cur = indentStack[indentStack.length - 1]; | |
| if (indent > cur) { indentStack.push(indent); push("indent", indent, lineNo); } | |
| else if (indent < cur) { | |
| while (indentStack.length > 1 && indent < indentStack[indentStack.length - 1]) { | |
| indentStack.pop(); push("dedent", indent, lineNo); | |
| } | |
| } | |
| const readString = (quote) => { | |
| pos++; | |
| let str = ""; | |
| while (pos < content.length) { | |
| const c = content[pos]; | |
| if (c === "\\") { | |
| const n = content[pos + 1]; | |
| if (n === "x" && /[0-9a-fA-F]/.test(content[pos + 2] || "") && /[0-9a-fA-F]/.test(content[pos + 3] || "")) { | |
| str += String.fromCharCode(parseInt(content.substr(pos + 2, 2), 16)); pos += 4; continue; | |
| } | |
| const map = { n: "\n", t: "\t", r: "\r", "\\": "\\", "'": "'", '"': '"', "0": "\0", a: "\x07", b: "\b", f: "\f", v: "\v" }; | |
| if (map[n] !== undefined) str += map[n]; | |
| else if (n !== undefined) str += n; | |
| pos += 2; continue; | |
| } | |
| if (c === quote) { pos++; return str; } | |
| str += c; pos++; | |
| } | |
| throw pyError("unterminated string literal", lineNo); | |
| }; | |
| let pos = 0; | |
| while (pos < content.length) { | |
| const ch = content[pos]; | |
| if (ch === " " || ch === "\t") { pos++; continue; } | |
| if (ch === "#") break; | |
| if (ch === '"' || ch === "'") { push("string", readString(ch), lineNo); continue; } | |
| if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(content[pos + 1] || ""))) { | |
| const m = content.slice(pos).match(/^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/); | |
| if (!m) throw pyError("invalid number literal", lineNo); | |
| push("number", Number(m[0]), lineNo); pos += m[0].length; continue; | |
| } | |
| if (/[a-zA-Z_]/.test(ch)) { | |
| let name = ""; | |
| while (pos < content.length && /[a-zA-Z0-9_]/.test(content[pos])) { name += content[pos]; pos++; } | |
| if ((name === "f" || name === "F") && (content[pos] === '"' || content[pos] === "'")) { | |
| push("fstring", readString(content[pos]), lineNo); continue; | |
| } | |
| push("name", name, lineNo); continue; | |
| } | |
| const two = content.substr(pos, 2); | |
| if (["**", "//", "==", "!=", "<=", ">=", "+=", "-=", "*=", "/=", "%=", "**=", "//="].indexOf(two) !== -1) { | |
| push("op", two, lineNo); pos += 2; continue; | |
| } | |
| if ("+-*/%<>=!()[]{},:.;".indexOf(ch) !== -1) { push("op", ch, lineNo); pos++; continue; } | |
| throw pyError(`unexpected character ${JSON.stringify(ch)}`, lineNo); | |
| } | |
| push("newline", "\n", lineNo); | |
| } | |
| while (indentStack.length > 1) { indentStack.pop(); push("dedent", 0, lines.length); } | |
| push("eof", null, lines.length); | |
| return tokens; | |
| } | |
| function pyParse(tokens) { | |
| let idx = 0; | |
| const at = (t, v) => { const tok = tokens[idx]; return tok && tok.type === t && (v === undefined || tok.value === v); }; | |
| const peek = (o) => tokens[Math.min(idx + (o || 0), tokens.length - 1)]; | |
| const next = () => tokens[idx++]; | |
| const match = (t, v) => { if (at(t, v)) { idx++; return true; } return false; }; | |
| const expect = (t, v) => { | |
| if (!at(t, v)) throw pyError(`syntax error: expected ${t}${v !== undefined ? " " + v : ""} near '${tokens[idx] ? tokens[idx].value : "EOF"}'`, tokens[idx] ? tokens[idx].line : 1); | |
| return next(); | |
| }; | |
| const atName = (v) => at("name", v); | |
| const matchName = (v) => match("name", v); | |
| const expectName = (v) => { if (!atName(v)) throw pyError(`syntax error: expected '${v}'`, peek(0).line); return next().value; }; | |
| const parseExprList = (stopAtIn) => { | |
| const first = parseExpression(stopAtIn); | |
| if (!match("op", ",")) return first; | |
| const items = [first]; | |
| while (true) { | |
| if (at("op", ")") || at("newline") || at("dedent") || at("eof") || at("op", ":")) break; | |
| items.push(parseExpression(stopAtIn)); | |
| if (!match("op", ",")) break; | |
| } | |
| return { type: "Tuple", elts: items }; | |
| }; | |
| const parseExpression = (stopAtIn) => parseOr(stopAtIn); | |
| const parseOr = (stopAtIn) => { | |
| let left = parseAnd(stopAtIn); | |
| while (atName("or")) { next(); left = { type: "BoolOp", op: "or", left, right: parseAnd(stopAtIn) }; } | |
| return left; | |
| }; | |
| const parseAnd = (stopAtIn) => { | |
| let left = parseNot(stopAtIn); | |
| while (atName("and")) { next(); left = { type: "BoolOp", op: "and", left, right: parseNot(stopAtIn) }; } | |
| return left; | |
| }; | |
| const parseNot = (stopAtIn) => { | |
| if (atName("not")) { next(); return { type: "UnaryOp", op: "not", operand: parseNot(stopAtIn) }; } | |
| return parseComparison(stopAtIn); | |
| }; | |
| const parseComparison = (stopAtIn) => { | |
| let left = parseAdd(); | |
| const ops = []; | |
| const comparators = []; | |
| while (true) { | |
| let op = null; | |
| if (at("op", "==") || at("op", "!=") || at("op", "<") || at("op", ">") || at("op", "<=") || at("op", ">=")) op = next().value; | |
| else if (atName("in") && !stopAtIn) { op = "in"; next(); } | |
| else if (atName("not") && !stopAtIn && peek(1).type === "name" && peek(1).value === "in") { op = "not in"; next(); next(); } | |
| else if (atName("is")) { next(); if (matchName("not")) op = "is not"; else op = "is"; } | |
| else break; | |
| ops.push(op); | |
| comparators.push(parseAdd()); | |
| } | |
| if (ops.length === 0) return left; | |
| return { type: "Compare", left, ops, comparators }; | |
| }; | |
| const parseAdd = () => { | |
| let left = parseMul(); | |
| while (at("op", "+") || at("op", "-")) { const op = next().value; left = { type: "BinOp", op, left, right: parseMul() }; } | |
| return left; | |
| }; | |
| const parseMul = () => { | |
| let left = parseUnary(); | |
| while (at("op", "*") || at("op", "/") || at("op", "//") || at("op", "%")) { const op = next().value; left = { type: "BinOp", op, left, right: parseUnary() }; } | |
| return left; | |
| }; | |
| const parseUnary = () => { | |
| if (at("op", "-")) { next(); return { type: "UnaryOp", op: "-", operand: parseUnary() }; } | |
| if (at("op", "+")) { next(); return { type: "UnaryOp", op: "+", operand: parseUnary() }; } | |
| return parsePower(); | |
| }; | |
| const parsePower = () => { | |
| const base = parsePostfix(); | |
| if (at("op", "**")) { next(); return { type: "BinOp", op: "**", left: base, right: parseUnary() }; } | |
| return base; | |
| }; | |
| const parsePostfix = () => { | |
| let expr = parseAtom(); | |
| while (true) { | |
| if (match("op", ".")) { | |
| expr = { type: "Attribute", value: expr, attr: expect("name").value }; | |
| } else if (at("op", "(")) { | |
| next(); | |
| const args = []; | |
| const kwargs = []; | |
| while (!at("op", ")")) { | |
| if (at("name") && peek(1).type === "op" && peek(1).value === "=") { | |
| const kwName = next().value; | |
| next(); | |
| kwargs.push({ name: kwName, value: parseExpression() }); | |
| } else { | |
| args.push(parseExpression()); | |
| } | |
| if (!match("op", ",")) break; | |
| } | |
| expect("op", ")"); | |
| expr = { type: "Call", func: expr, args, kwargs }; | |
| } else if (at("op", "[")) { | |
| next(); | |
| let lower = null, upper = null, step = null, hasColon = false; | |
| if (!at("op", ":") && !at("op", "]")) lower = parseExpression(); | |
| if (match("op", ":")) { | |
| hasColon = true; | |
| if (!at("op", ":") && !at("op", "]")) upper = parseExpression(); | |
| if (match("op", ":")) { if (!at("op", "]")) step = parseExpression(); } | |
| } | |
| expect("op", "]"); | |
| expr = hasColon | |
| ? { type: "Subscript", value: expr, slice: { lower, upper, step } } | |
| : { type: "Subscript", value: expr, index: lower }; | |
| } else break; | |
| } | |
| return expr; | |
| }; | |
| const parseAtom = () => { | |
| const tok = next(); | |
| if (tok.type === "number") return { type: "Num", value: tok.value }; | |
| if (tok.type === "string") return { type: "Str", value: tok.value }; | |
| if (tok.type === "fstring") return { type: "FStr", value: tok.value }; | |
| if (tok.type === "name") { | |
| if (tok.value === "True") return { type: "Num", value: true }; | |
| if (tok.value === "False") return { type: "Num", value: false }; | |
| if (tok.value === "None") return { type: "Num", value: null }; | |
| return { type: "Name", name: tok.value }; | |
| } | |
| if (tok.type === "op") { | |
| if (tok.value === "(") { | |
| const inner = parseExprList(); | |
| expect("op", ")"); | |
| if (inner.type === "Tuple") return inner; | |
| return { type: "Paren", expr: inner }; | |
| } | |
| if (tok.value === "[") { | |
| const elts = []; | |
| if (!at("op", "]")) { | |
| while (true) { | |
| elts.push(parseExpression()); | |
| if (!match("op", ",")) break; | |
| } | |
| } | |
| expect("op", "]"); | |
| return { type: "List", elts }; | |
| } | |
| if (tok.value === "{") { | |
| const keys = []; | |
| const values = []; | |
| if (!at("op", "}")) { | |
| while (true) { | |
| keys.push(parseExpression()); | |
| expect("op", ":"); | |
| values.push(parseExpression()); | |
| if (!match("op", ",")) break; | |
| } | |
| } | |
| expect("op", "}"); | |
| return { type: "Dict", keys, values }; | |
| } | |
| } | |
| throw pyError(`syntax error: unexpected token '${tok.value}'`, tok.line); | |
| }; | |
| const parseBlock = () => { | |
| if (at("newline")) { | |
| next(); | |
| if (!at("indent")) throw pyError("expected an indented block", peek(0).line); | |
| next(); | |
| const stmts = []; | |
| while (!at("dedent") && !at("eof")) { | |
| while (at("newline")) next(); | |
| if (at("dedent") || at("eof")) break; | |
| stmts.push(parseStatement()); | |
| } | |
| if (at("dedent")) next(); | |
| return stmts; | |
| } | |
| const stmts = []; | |
| while (true) { | |
| stmts.push(parseStatement()); | |
| if (match("op", ";")) continue; | |
| break; | |
| } | |
| return stmts; | |
| }; | |
| const parseDef = () => { | |
| const name = expect("name").value; | |
| expect("op", "("); | |
| const params = []; | |
| const defaults = []; | |
| let varargs = null; | |
| let kwargs = null; | |
| while (!at("op", ")")) { | |
| if (match("op", "*")) { varargs = expect("name").value; if (match("op", ",")) continue; break; } | |
| if (match("op", "**")) { kwargs = expect("name").value; if (match("op", ",")) continue; break; } | |
| params.push(expect("name").value); | |
| if (match("op", "=")) defaults.push(parseExpression()); | |
| else defaults.push(null); | |
| if (!match("op", ",")) break; | |
| } | |
| expect("op", ")"); | |
| expect("op", ":"); | |
| return { type: "Def", name, params, defaults, varargs, kwargs, body: parseBlock() }; | |
| }; | |
| const parseStatement = () => { | |
| if (matchName("def")) return parseDef(); | |
| if (matchName("if")) { | |
| const branches = []; | |
| branches.push({ test: parseExpression(), body: parseIfBody() }); | |
| while (matchName("elif")) branches.push({ test: parseExpression(), body: parseIfBody() }); | |
| let orelse = []; | |
| if (matchName("else")) orelse = parseIfBody(); | |
| return { type: "If", branches, orelse }; | |
| } | |
| if (matchName("while")) { const test = parseExpression(); expect("op", ":"); return { type: "While", test, body: parseBlock() }; } | |
| if (matchName("for")) { const target = parseExprList(true); expectName("in"); const iter = parseExpression(); expect("op", ":"); return { type: "For", target, iter, body: parseBlock() }; } | |
| if (matchName("return")) { if (at("newline") || at("dedent") || at("eof")) return { type: "Return", value: null }; return { type: "Return", value: parseExprList() }; } | |
| if (matchName("break")) return { type: "Break" }; | |
| if (matchName("continue")) return { type: "Continue" }; | |
| if (matchName("pass")) return { type: "Pass" }; | |
| if (matchName("import")) { | |
| const names = [expect("name").value]; | |
| while (match("op", ",")) names.push(expect("name").value); | |
| return { type: "Import", names }; | |
| } | |
| if (matchName("from")) { | |
| const module = expect("name").value; | |
| expectName("import"); | |
| const names = [expect("name").value]; | |
| while (match("op", ",")) names.push(expect("name").value); | |
| return { type: "ImportFrom", module, names }; | |
| } | |
| if (atName("else") || atName("elif")) { | |
| throw pyError(`'${peek(0).value}' without a preceding 'if' (for/while-else is not supported)`, peek(0).line); | |
| } | |
| const expr = parseExprList(); | |
| if (at("op", "=")) { next(); return { type: "Assign", targets: expr, value: parseExprList() }; } | |
| if (at("op", "+=") || at("op", "-=") || at("op", "*=") || at("op", "/=") || at("op", "%=") || at("op", "**=") || at("op", "//=")) { | |
| const op = next().value.slice(0, -1); | |
| return { type: "AugAssign", target: expr, op, value: parseExpression() }; | |
| } | |
| return { type: "Expr", value: expr }; | |
| }; | |
| const parseIfBody = () => { expect("op", ":"); return parseBlock(); }; | |
| const parseProgram = () => { | |
| const body = []; | |
| while (!at("eof")) { | |
| while (at("newline")) next(); | |
| if (at("eof")) break; | |
| if (at("dedent")) { next(); continue; } | |
| body.push(parseStatement()); | |
| while (match("op", ";")) {} | |
| } | |
| return { type: "Program", body }; | |
| }; | |
| return parseProgram(); | |
| } | |
| // --- Environments --------------------------------------------------------- | |
| function pyMakeEnv(parent) { return { parent: parent || null, vars: new Map() }; } | |
| function pyLookup(name, env) { | |
| let e = env; | |
| while (e) { | |
| if (e.vars.has(name)) return e.vars.get(name); | |
| e = e.parent; | |
| } | |
| throw pyError(`name '${name}' is not defined`); | |
| } | |
| function pySet(name, value, env) { env.vars.set(name, value); } | |
| function pyMakeDict() { return { __py: "dict", m: new Map() }; } | |
| function pyTuple(items) { return { __py: "tuple", items }; } | |
| function pyIsSeq(v) { return Array.isArray(v) || (v && v.__py === "tuple"); } | |
| function pyDictKey(k) { return typeof k === "boolean" ? (k ? 1 : 0) : k; } | |
| function pyMakeFunction(s, env, I) { | |
| return { | |
| __py: "func", | |
| name: s.name, | |
| params: s.params, | |
| defaults: s.defaults.map(d => (d !== null ? pyEval(d, env, I) : null)), | |
| varargs: s.varargs, | |
| kwargs: s.kwargs, | |
| body: s.body, | |
| env, | |
| }; | |
| } | |
| function pyImportModule(name, I) { | |
| if (name === "math") { | |
| if (!I.mathModule) I.mathModule = pyMakeMathModule(); | |
| return I.mathModule; | |
| } | |
| return null; | |
| } | |
| // --- Statements ----------------------------------------------------------- | |
| function pyExecBlock(stmts, env, I) { | |
| for (const s of stmts) pyExecStatement(s, env, I); | |
| } | |
| function pyExecStatement(s, env, I) { | |
| switch (s.type) { | |
| case "Expr": | |
| I.lastExprValue = pyEval(s.value, env, I); | |
| return; | |
| case "Assign": | |
| pyAssignTarget(s.targets, pyEval(s.value, env, I), env, I); | |
| return; | |
| case "AugAssign": | |
| pySetTarget(s.target, pyBinOp(s.op, pyEval(s.target, env, I), pyEval(s.value, env, I), I), env, I); | |
| return; | |
| case "If": | |
| for (const branch of s.branches) { | |
| if (pyTruthy(pyEval(branch.test, env, I))) { pyExecBlock(branch.body, env, I); return; } | |
| } | |
| pyExecBlock(s.orelse, env, I); | |
| return; | |
| case "While": { | |
| let guard = 0; | |
| while (pyTruthy(pyEval(s.test, env, I))) { | |
| if (++guard > I.iterBudget) throw pyError("maximum loop iterations exceeded (simulated interpreter safety limit)"); | |
| try { pyExecBlock(s.body, env, I); } | |
| catch (e) { if (e && e.__pySignal === "break") break; if (e && e.__pySignal === "continue") continue; throw e; } | |
| } | |
| return; | |
| } | |
| case "For": { | |
| const items = pyIterList(pyEval(s.iter, env, I), I); | |
| let guard = 0; | |
| for (const item of items) { | |
| if (++guard > I.iterBudget) throw pyError("maximum loop iterations exceeded (simulated interpreter safety limit)"); | |
| pyAssignTarget(s.target, item, env, I); | |
| try { pyExecBlock(s.body, env, I); } | |
| catch (e) { if (e && e.__pySignal === "break") break; if (e && e.__pySignal === "continue") continue; throw e; } | |
| } | |
| return; | |
| } | |
| case "Def": | |
| pySet(s.name, pyMakeFunction(s, env, I), env); | |
| return; | |
| case "Return": | |
| throw pySignal("return", s.value !== null ? pyEval(s.value, env, I) : null); | |
| case "Break": | |
| throw pySignal("break"); | |
| case "Continue": | |
| throw pySignal("continue"); | |
| case "Pass": | |
| return; | |
| case "Import": | |
| for (const n of s.names) { | |
| const mod = pyImportModule(n, I); | |
| if (mod === null) throw pyError(`No module named '${n}'`); | |
| pySet(n, mod, env); | |
| } | |
| return; | |
| case "ImportFrom": { | |
| const mod = pyImportModule(s.module, I); | |
| if (mod === null) throw pyError(`No module named '${s.module}'`); | |
| for (const n of s.names) { | |
| if (mod.map.has(n)) pySet(n, mod.map.get(n), env); | |
| else throw pyError(`cannot import name '${n}' from '${s.module}'`); | |
| } | |
| return; | |
| } | |
| default: | |
| throw pyError(`unsupported statement: ${s.type}`); | |
| } | |
| } | |
| function pyAssignTarget(target, value, env, I) { | |
| if (target.type === "Name") { pySet(target.name, value, env); return; } | |
| if (target.type === "Paren") { pyAssignTarget(target.expr, value, env, I); return; } | |
| if (target.type === "Tuple") { | |
| const values = pyIterList(value, I); | |
| if (values.length !== target.elts.length) throw pyError(`cannot unpack ${values.length} values into ${target.elts.length} targets`); | |
| for (let i = 0; i < target.elts.length; i++) pyAssignTarget(target.elts[i], values[i], env, I); | |
| return; | |
| } | |
| if (target.type === "Subscript") { | |
| const base = pyEval(target.value, env, I); | |
| if (target.index === null || target.index === undefined) throw pyError("cannot assign to a slice"); | |
| pySubscriptSet(base, pyEval(target.index, env, I), value, I); | |
| return; | |
| } | |
| if (target.type === "Attribute") { | |
| const base = pyEval(target.value, env, I); | |
| if (base && base.__py === "dict") { base.m.set(target.attr, value); return; } | |
| throw pyError(`cannot set attribute '${target.attr}'`); | |
| } | |
| throw pyError("cannot assign to this expression"); | |
| } | |
| function pySetTarget(target, value, env, I) { | |
| if (target.type === "Name") { pySet(target.name, value, env); return; } | |
| if (target.type === "Paren") { pySetTarget(target.expr, value, env, I); return; } | |
| if (target.type === "Subscript") { | |
| const base = pyEval(target.value, env, I); | |
| pySubscriptSet(base, pyEval(target.index, env, I), value, I); | |
| return; | |
| } | |
| throw pyError("cannot assign to this expression"); | |
| } | |
| // --- Expressions ---------------------------------------------------------- | |
| function pyEval(expr, env, I) { | |
| switch (expr.type) { | |
| case "Num": return expr.value; | |
| case "Str": return expr.value; | |
| case "FStr": return pyEvalFString(expr.value, env, I); | |
| case "Name": return pyLookup(expr.name, env); | |
| case "Paren": return pyEval(expr.expr, env, I); | |
| case "Tuple": return expr.elts.map(e => pyEval(e, env, I)); | |
| case "List": return expr.elts.map(e => pyEval(e, env, I)); | |
| case "Dict": { | |
| const d = pyMakeDict(); | |
| for (let i = 0; i < expr.keys.length; i++) d.m.set(pyDictKey(pyEval(expr.keys[i], env, I)), pyEval(expr.values[i], env, I)); | |
| return d; | |
| } | |
| case "BinOp": return pyBinOp(expr.op, pyEval(expr.left, env, I), pyEval(expr.right, env, I), I); | |
| case "UnaryOp": { | |
| const v = pyEval(expr.operand, env, I); | |
| if (expr.op === "-") return -v; | |
| if (expr.op === "+") return +v; | |
| if (expr.op === "not") return !pyTruthy(v); | |
| throw pyError("unsupported unary operator"); | |
| } | |
| case "BoolOp": { | |
| const left = pyEval(expr.left, env, I); | |
| if (expr.op === "and") return pyTruthy(left) ? pyEval(expr.right, env, I) : left; | |
| return pyTruthy(left) ? left : pyEval(expr.right, env, I); | |
| } | |
| case "Compare": { | |
| let left = pyEval(expr.left, env, I); | |
| for (let i = 0; i < expr.ops.length; i++) { | |
| const right = pyEval(expr.comparators[i], env, I); | |
| if (!pyCompare(expr.ops[i], left, right)) return false; | |
| left = right; | |
| } | |
| return true; | |
| } | |
| case "Attribute": return pyAttr(pyEval(expr.value, env, I), expr.attr, I); | |
| case "Subscript": { | |
| const base = pyEval(expr.value, env, I); | |
| if (expr.slice) { | |
| const lower = expr.slice.lower !== null ? pyEval(expr.slice.lower, env, I) : null; | |
| const upper = expr.slice.upper !== null ? pyEval(expr.slice.upper, env, I) : null; | |
| const step = expr.slice.step !== null ? pyEval(expr.slice.step, env, I) : null; | |
| return pySlice(base, lower, upper, step, I); | |
| } | |
| return pySubscript(base, pyEval(expr.index, env, I), I); | |
| } | |
| case "Call": { | |
| const func = pyEval(expr.func, env, I); | |
| const args = expr.args.map(a => pyEval(a, env, I)); | |
| const kwargs = {}; | |
| for (const kw of expr.kwargs) kwargs[kw.name] = pyEval(kw.value, env, I); | |
| return pyCall(func, args, kwargs, I); | |
| } | |
| default: | |
| throw pyError(`unsupported expression: ${expr.type}`); | |
| } | |
| } | |
| // --- Operators / values --------------------------------------------------- | |
| function pyBinOp(op, a, b, I) { | |
| const typeErr = (op) => pyError(`unsupported operand type(s) for ${op}: '${pyTypeName(a)}' and '${pyTypeName(b)}'`); | |
| switch (op) { | |
| case "+": | |
| if (typeof a === "number" && typeof b === "number") return a + b; | |
| if (typeof a === "string" && typeof b === "string") return a + b; | |
| if (Array.isArray(a) && Array.isArray(b)) return a.concat(b); | |
| if (a && a.__py === "tuple" && b && b.__py === "tuple") return pyTuple(a.items.concat(b.items)); | |
| throw typeErr("+"); | |
| case "-": | |
| if (typeof a !== "number" || typeof b !== "number") throw typeErr("-"); | |
| return a - b; | |
| case "*": | |
| if (typeof a === "number" && typeof b === "number") return a * b; | |
| if (typeof a === "string" && typeof b === "number") return b < 0 ? "" : a.repeat(b); | |
| if (typeof a === "number" && typeof b === "string") return a < 0 ? "" : b.repeat(a); | |
| if (Array.isArray(a) && typeof b === "number") { const out = []; for (let i = 0; i < Math.max(0, b); i++) out.push(...a); return out; } | |
| if (a && a.__py === "tuple" && typeof b === "number") { const out = []; for (let i = 0; i < Math.max(0, b); i++) out.push(...a.items); return pyTuple(out); } | |
| throw typeErr("*"); | |
| case "/": | |
| if (typeof a !== "number" || typeof b !== "number") throw typeErr("/"); | |
| if (b === 0) throw pyError("division by zero"); | |
| return a / b; | |
| case "//": | |
| if (typeof a !== "number" || typeof b !== "number") throw typeErr("//"); | |
| if (b === 0) throw pyError("integer division or modulo by zero"); | |
| return Math.floor(a / b); | |
| case "%": | |
| if (typeof a !== "number" || typeof b !== "number") throw typeErr("%"); | |
| if (b === 0) throw pyError("integer division or modulo by zero"); | |
| return ((a % b) + b) % b; | |
| case "**": | |
| if (typeof a !== "number" || typeof b !== "number") throw typeErr("**"); | |
| return Math.pow(a, b); | |
| default: | |
| throw pyError(`unsupported operator ${op}`); | |
| } | |
| } | |
| function pyCompare(op, a, b) { | |
| switch (op) { | |
| case "==": return pyEquals(a, b); | |
| case "!=": return !pyEquals(a, b); | |
| case "is": return pyEquals(a, b); | |
| case "is not": return !pyEquals(a, b); | |
| case "<": return pyOrder(a, b) < 0; | |
| case ">": return pyOrder(a, b) > 0; | |
| case "<=": return pyOrder(a, b) <= 0; | |
| case ">=": return pyOrder(a, b) >= 0; | |
| case "in": return pyContains(a, b); | |
| case "not in": return !pyContains(a, b); | |
| default: throw pyError(`unsupported comparison operator ${op}`); | |
| } | |
| } | |
| function pyOrder(a, b) { | |
| if (typeof a === "number" && typeof b === "number") return a - b; | |
| if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0; | |
| throw pyError(`'<=' not supported between instances of '${pyTypeName(a)}' and '${pyTypeName(b)}'`); | |
| } | |
| function pyEquals(a, b) { | |
| if (a === null || b === null) return a === b; | |
| if (pyIsSeq(a) && pyIsSeq(b)) { | |
| const aItems = a.__py === "tuple" ? a.items : a; | |
| const bItems = b.__py === "tuple" ? b.items : b; | |
| if (aItems.length !== bItems.length) return false; | |
| for (let i = 0; i < aItems.length; i++) if (!pyEquals(aItems[i], bItems[i])) return false; | |
| return true; | |
| } | |
| if (a && a.__py === "dict" && b && b.__py === "dict") { | |
| if (a.m.size !== b.m.size) return false; | |
| for (const [k, v] of a.m) if (!b.m.has(k) || !pyEquals(v, b.m.get(k))) return false; | |
| return true; | |
| } | |
| if (typeof a === "boolean" || typeof b === "boolean") return a === b; | |
| return a === b; | |
| } | |
| function pyContains(needle, container) { | |
| if (pyIsSeq(container)) { | |
| const items = container.__py === "tuple" ? container.items : container; | |
| return items.some(x => pyEquals(x, needle)); | |
| } | |
| if (typeof container === "string") { | |
| if (typeof needle !== "string") throw pyError("'in <string>' requires string as left operand"); | |
| return container.indexOf(needle) !== -1; | |
| } | |
| if (container && container.__py === "dict") return container.m.has(pyDictKey(needle)); | |
| throw pyError(`argument of type '${pyTypeName(container)}' is not iterable`); | |
| } | |
| function pyTruthy(v) { | |
| if (v === null || v === false) return false; | |
| if (typeof v === "number") return v !== 0; | |
| if (typeof v === "string") return v.length > 0; | |
| if (Array.isArray(v)) return v.length > 0; | |
| if (v && v.__py === "dict") return v.m.size > 0; | |
| return true; | |
| } | |
| function pyStr(v) { | |
| if (v === null) return "None"; | |
| if (v === true) return "True"; | |
| if (v === false) return "False"; | |
| if (typeof v === "number") return String(v); | |
| if (typeof v === "string") return v; | |
| if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]"; | |
| if (v && v.__py === "tuple") return v.items.length === 1 ? "(" + pyRepr(v.items[0]) + ",)" : "(" + v.items.map(pyRepr).join(", ") + ")"; | |
| if (v && v.__py === "dict") { | |
| const parts = []; | |
| v.m.forEach((val, k) => parts.push(pyRepr(k) + ": " + pyRepr(val))); | |
| return "{" + parts.join(", ") + "}"; | |
| } | |
| if (v && v.__py === "module") return `<module '${v.name}'>`; | |
| if (v && v.__py === "func") return `<function ${v.name}>`; | |
| return String(v); | |
| } | |
| function pyRepr(v) { | |
| if (typeof v === "string") return "'" + v.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; | |
| return pyStr(v); | |
| } | |
| function pyTypeName(v) { | |
| if (v === null) return "NoneType"; | |
| if (typeof v === "boolean") return "bool"; | |
| if (typeof v === "number") return Number.isInteger(v) ? "int" : "float"; | |
| if (typeof v === "string") return "str"; | |
| if (Array.isArray(v)) return "list"; | |
| if (v && v.__py === "tuple") return "tuple"; | |
| if (v && v.__py === "dict") return "dict"; | |
| if (v && v.__py === "func") return "function"; | |
| if (v && v.__py === "module") return "module"; | |
| return typeof v; | |
| } | |
| function pyIterList(v, I) { | |
| if (Array.isArray(v)) return v; | |
| if (v && v.__py === "tuple") return v.items; | |
| if (typeof v === "string") return v.split(""); | |
| if (v && v.__py === "dict") return Array.from(v.m.keys()); | |
| if (v === null) throw pyError("'NoneType' object is not iterable"); | |
| if (typeof v === "number") throw pyError("'int' object is not iterable"); | |
| throw pyError(`'${pyTypeName(v)}' object is not iterable`); | |
| } | |
| function pyIndex(k, len, allowEnd) { | |
| let idx = Math.trunc(k); | |
| if (idx < 0) idx += len; | |
| const max = allowEnd ? len : len - 1; | |
| if (idx < 0 || idx > max) throw pyError("index out of range"); | |
| return idx; | |
| } | |
| function pySubscript(obj, key, I) { | |
| if (pyIsSeq(obj)) return (obj.__py === "tuple" ? obj.items : obj)[pyIndex(key, obj.length)]; | |
| if (typeof obj === "string") return obj[pyIndex(key, obj.length)]; | |
| if (obj && obj.__py === "dict") { | |
| const k = pyDictKey(key); | |
| if (!obj.m.has(k)) throw pyError(`KeyError: ${pyStr(key)}`); | |
| return obj.m.get(k); | |
| } | |
| throw pyError(`'${pyTypeName(obj)}' object is not subscriptable`); | |
| } | |
| function pySubscriptSet(obj, key, value, I) { | |
| if (Array.isArray(obj)) { obj[pyIndex(key, obj.length)] = value; return; } | |
| if (typeof obj === "string") throw pyError("'str' object does not support item assignment"); | |
| if (obj && obj.__py === "tuple") throw pyError("'tuple' object does not support item assignment"); | |
| if (obj && obj.__py === "dict") { obj.m.set(pyDictKey(key), value); return; } | |
| throw pyError(`'${pyTypeName(obj)}' object does not support item assignment`); | |
| } | |
| function pySliceSeq(len, lo, hi, step) { | |
| if (step === null || step === undefined) step = 1; | |
| if (step === 0) throw pyError("slice step cannot be zero"); | |
| const adj = (v, dflt) => { | |
| if (v === null || v === undefined) return dflt; | |
| let n = Math.trunc(v); | |
| if (n < 0) n += len; | |
| if (step > 0) return Math.max(0, Math.min(n, len)); | |
| return Math.max(-1, Math.min(n, len - 1)); | |
| }; | |
| const result = []; | |
| if (step > 0) { | |
| let i = adj(lo, 0); | |
| const stop = adj(hi, len); | |
| for (; i < stop; i += step) result.push(i); | |
| } else { | |
| let i = adj(lo, len - 1); | |
| const stop = adj(hi, -1); | |
| for (; i > stop; i += step) result.push(i); | |
| } | |
| return result; | |
| } | |
| function pySlice(obj, lower, upper, step, I) { | |
| if (Array.isArray(obj)) return pySliceSeq(obj.length, lower, upper, step).map(i => obj[i]); | |
| if (obj && obj.__py === "tuple") return pyTuple(pySliceSeq(obj.items.length, lower, upper, step).map(i => obj.items[i])); | |
| if (typeof obj === "string") return pySliceSeq(obj.length, lower, upper, step).map(i => obj[i]).join(""); | |
| if (obj && obj.__py === "dict") { | |
| const keys = Array.from(obj.m.keys()); | |
| return pySliceSeq(keys.length, lower, upper, step).map(i => obj.m.get(keys[i])); | |
| } | |
| throw pyError(`'${pyTypeName(obj)}' object is not subscriptable`); | |
| } | |
| // --- Attributes / calls --------------------------------------------------- | |
| function pyAttr(obj, name, I) { | |
| if (Array.isArray(obj)) return pyListMethod(obj, name, I); | |
| if (typeof obj === "string") return pyStrMethod(obj, name, I); | |
| if (obj && obj.__py === "dict") return pyDictMethod(obj, name, I); | |
| if (obj && obj.__py === "module") { | |
| if (obj.map.has(name)) return obj.map.get(name); | |
| throw pyError(`module '${obj.name}' has no attribute '${name}'`); | |
| } | |
| throw pyError(`'${pyTypeName(obj)}' object has no attribute '${name}'`); | |
| } | |
| function pyCall(func, args, kwargs, I) { | |
| if (typeof func === "function") { | |
| try { return func(args, kwargs); } | |
| catch (e) { if (e && e.__pyError) throw e; throw pyError(e && e.message ? e.message : String(e)); } | |
| } | |
| if (func && func.__py === "func") { | |
| const callEnv = pyMakeEnv(func.env); | |
| let pi = 0; | |
| const usedKw = new Set(); | |
| for (let i = 0; i < func.params.length; i++) { | |
| let value; | |
| if (kwargs.hasOwnProperty(func.params[i])) { value = kwargs[func.params[i]]; usedKw.add(func.params[i]); } | |
| else if (pi < args.length) { value = args[pi]; pi++; } | |
| else if (func.defaults[i] !== null) value = func.defaults[i]; | |
| else throw pyError(`${func.name}() missing required argument: '${func.params[i]}'`); | |
| callEnv.vars.set(func.params[i], value); | |
| } | |
| if (func.varargs) callEnv.vars.set(func.varargs, pyTuple(args.slice(pi))); | |
| if (func.kwargs) { | |
| const d = pyMakeDict(); | |
| for (const k in kwargs) if (!usedKw.has(k)) d.m.set(k, kwargs[k]); | |
| callEnv.vars.set(func.kwargs, d); | |
| } else { | |
| const extra = Object.keys(kwargs).filter(k => !usedKw.has(k)); | |
| if (extra.length) throw pyError(`${func.name}() got an unexpected keyword argument '${extra[0]}'`); | |
| } | |
| try { pyExecBlock(func.body, callEnv, I); return null; } | |
| catch (e) { if (e && e.__pySignal === "return") return e.__pyValue; throw e; } | |
| } | |
| throw pyError(`'${pyTypeName(func)}' object is not callable`); | |
| } | |
| // --- Builtins ------------------------------------------------------------- | |
| function pyMakeMathModule() { | |
| const math = { __py: "module", name: "math", map: new Map() }; | |
| const f = (fn) => (args) => fn(args); | |
| const vals = { | |
| pi: Math.PI, | |
| e: Math.E, | |
| tau: Math.PI * 2, | |
| sqrt: f(a => Math.sqrt(a[0])), | |
| sin: f(a => Math.sin(a[0])), | |
| cos: f(a => Math.cos(a[0])), | |
| tan: f(a => Math.tan(a[0])), | |
| asin: f(a => Math.asin(a[0])), | |
| acos: f(a => Math.acos(a[0])), | |
| atan: f(a => Math.atan(a[0])), | |
| atan2: f(a => Math.atan2(a[0], a[1])), | |
| exp: f(a => Math.exp(a[0])), | |
| log: f(a => a.length > 1 ? Math.log(a[0]) / Math.log(a[1]) : Math.log(a[0])), | |
| log2: f(a => Math.log2(a[0])), | |
| log10: f(a => Math.log10(a[0])), | |
| floor: f(a => Math.floor(a[0])), | |
| ceil: f(a => Math.ceil(a[0])), | |
| fabs: f(a => Math.abs(a[0])), | |
| fmod: f(a => { const r = a[0] % a[1]; return (r + a[1]) % a[1]; }), | |
| hypot: f(a => Math.hypot(...a)), | |
| degrees: f(a => a[0] * 180 / Math.PI), | |
| radians: f(a => a[0] * Math.PI / 180), | |
| pow: f(a => Math.pow(a[0], a[1])), | |
| factorial: f(a => { | |
| let n = Math.trunc(a[0]); | |
| if (n < 0) throw pyError("factorial() not defined for negative values"); | |
| let r = 1; | |
| for (let i = 2; i <= n; i++) r *= i; | |
| return r; | |
| }), | |
| }; | |
| for (const k in vals) math.map.set(k, vals[k]); | |
| return math; | |
| } | |
| function pyBuiltins(I) { | |
| const b = { | |
| print: (args, kwargs) => { | |
| const sep = kwargs.sep !== undefined ? kwargs.sep : " "; | |
| const end = kwargs.end !== undefined ? kwargs.end : "\n"; | |
| I.outputs.push(args.map(pyStr).join(sep) + (end === "\n" ? "" : end)); | |
| return null; | |
| }, | |
| range: (args) => { | |
| let start = 0, stop, step = 1; | |
| if (args.length === 1) stop = args[0]; | |
| else if (args.length === 2) { start = args[0]; stop = args[1]; } | |
| else if (args.length >= 3) { start = args[0]; stop = args[1]; step = args[2]; } | |
| else throw pyError("range expected at least 1 argument, got 0"); | |
| if (step === 0) throw pyError("range() arg 3 must not be zero"); | |
| const out = []; | |
| if (step > 0) for (let i = start; i < stop; i += step) out.push(i); | |
| else for (let i = start; i > stop; i += step) out.push(i); | |
| return out; | |
| }, | |
| len: (args) => { | |
| const v = args[0]; | |
| if (pyIsSeq(v)) return (v.__py === "tuple" ? v.items : v).length; | |
| if (typeof v === "string") return v.length; | |
| if (v && v.__py === "dict") return v.m.size; | |
| throw pyError(`object of type '${pyTypeName(v)}' has no len()`); | |
| }, | |
| sum: (args) => { | |
| const arr = pyIterList(args[0], I); | |
| return arr.reduce((acc, x) => acc + x, 0); | |
| }, | |
| abs: (args) => Math.abs(args[0]), | |
| min: (args) => (args.length === 1 ? args[0] : args).reduce((m, x) => (pyOrder(x, m) < 0 ? x : m)), | |
| max: (args) => (args.length === 1 ? args[0] : args).reduce((m, x) => (pyOrder(x, m) > 0 ? x : m)), | |
| pow: (args) => Math.pow(args[0], args[1]), | |
| round: (args) => { | |
| const n = args[0]; | |
| const nd = args[1] || 0; | |
| const f = Math.pow(10, nd); | |
| return Math.round(n * f) / f; | |
| }, | |
| int: (args) => { | |
| const v = args[0]; | |
| if (typeof v === "string") return parseInt(v.trim(), 10) || 0; | |
| return Math.trunc(v); | |
| }, | |
| float: (args) => parseFloat(args[0]), | |
| str: (args) => pyStr(args[0]), | |
| bool: (args) => pyTruthy(args[0]), | |
| list: (args) => Array.from(pyIterList(args[0], I)), | |
| dict: (args) => { | |
| const d = pyMakeDict(); | |
| if (args[0] && args[0].__py === "dict") args[0].m.forEach((v, k) => d.m.set(k, v)); | |
| return d; | |
| }, | |
| sorted: (args) => { | |
| const arr = pyIterList(args[0], I).slice(); | |
| arr.sort((a, b) => { | |
| if (typeof a === "number" && typeof b === "number") return a - b; | |
| return pyOrder(a, b); | |
| }); | |
| return arr; | |
| }, | |
| reversed: (args) => pyIterList(args[0], I).slice().reverse(), | |
| enumerate: (args) => pyIterList(args[0], I).map((v, i) => pyTuple([i, v])), | |
| zip: (args) => { | |
| const lists = args.map(a => pyIterList(a, I)); | |
| const n = Math.min(...lists.map(l => l.length)); | |
| const out = []; | |
| for (let i = 0; i < n; i++) out.push(pyTuple(lists.map(l => l[i]))); | |
| return out; | |
| }, | |
| tuple: (args) => pyTuple(pyIterList(args[0], I)), | |
| input: () => { throw pyError("input() is not supported in this simulated environment"); }, | |
| type: (args) => `<class '${pyTypeName(args[0])}'>`, | |
| }; | |
| b.math = pyMakeMathModule(); | |
| return b; | |
| } | |
| // --- String / list / dict methods ---------------------------------------- | |
| function pyRegexEsc(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } | |
| function pyStrMethod(obj, name, I) { | |
| const m = { | |
| upper: () => obj.toUpperCase(), | |
| lower: () => obj.toLowerCase(), | |
| capitalize: () => (obj ? obj[0].toUpperCase() + obj.slice(1).toLowerCase() : ""), | |
| title: () => obj.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()), | |
| strip: (a) => (a[0] !== undefined ? obj.replace(new RegExp("^[" + pyRegexEsc(a[0]) + "]+|[" + pyRegexEsc(a[0]) + "]+$", "g"), "") : obj.trim()), | |
| lstrip: (a) => (a[0] !== undefined ? obj.replace(new RegExp("^[" + pyRegexEsc(a[0]) + "]+", "g"), "") : obj.replace(/^\s+/, "")), | |
| rstrip: (a) => (a[0] !== undefined ? obj.replace(new RegExp("[" + pyRegexEsc(a[0]) + "]+$", "g"), "") : obj.replace(/\s+$/, "")), | |
| split: (a) => { | |
| if (a[0] === undefined) return obj.trim() === "" ? [] : obj.trim().split(/\s+/); | |
| return obj.split(a[0]); | |
| }, | |
| rsplit: (a) => { | |
| if (a[0] === undefined) return obj.trim() === "" ? [] : obj.trim().split(/\s+/); | |
| return obj.split(a[0]); | |
| }, | |
| splitlines: () => obj.split(/\r?\n/), | |
| join: (a) => pyIterList(a[0], I).map(pyStr).join(obj), | |
| replace: (a) => obj.split(a[0]).join(a[1]), | |
| startswith: (a) => obj.startsWith(a[0]), | |
| endswith: (a) => obj.endsWith(a[0]), | |
| find: (a) => obj.indexOf(a[0]), | |
| rfind: (a) => obj.lastIndexOf(a[0]), | |
| index: (a) => { const i = obj.indexOf(a[0]); if (i === -1) throw pyError("substring not found"); return i; }, | |
| count: (a) => { | |
| let c = 0; | |
| let i = 0; | |
| const sub = String(a[0]); | |
| while (true) { | |
| i = obj.indexOf(sub, i); | |
| if (i === -1) break; | |
| c++; | |
| i += Math.max(sub.length, 1); | |
| } | |
| return c; | |
| }, | |
| isdigit: () => /^[0-9]+$/.test(obj), | |
| isalpha: () => /^[A-Za-z]+$/.test(obj), | |
| isspace: () => /^\s+$/.test(obj), | |
| islower: () => obj === obj.toLowerCase() && /[a-z]/.test(obj), | |
| isupper: () => obj === obj.toUpperCase() && /[A-Z]/.test(obj), | |
| zfill: (a) => obj.padStart(Math.trunc(a[0]), "0"), | |
| format: (a, kw) => { | |
| let seq = 0; | |
| return obj.replace(/\{([^{}]*)\}/g, (mm, key) => { | |
| if (key === "") { const v = a[seq++]; return v === undefined ? mm : pyStr(v); } | |
| if (/^\d+$/.test(key)) { const v = a[parseInt(key, 10)]; return v === undefined ? mm : pyStr(v); } | |
| return kw && kw.hasOwnProperty(key) ? pyStr(kw[key]) : mm; | |
| }); | |
| }, | |
| }; | |
| if (m[name] === undefined) throw pyError(`'str' object has no attribute '${name}'`); | |
| return (args, kwargs) => m[name](args, kwargs); | |
| } | |
| function pyListMethod(list, name, I) { | |
| const m = { | |
| append: (a) => { list.push(a[0]); return null; }, | |
| extend: (a) => { for (const x of pyIterList(a[0], I)) list.push(x); return null; }, | |
| insert: (a) => { list.splice(pyIndex(a[0], list.length, true), 0, a[1]); return null; }, | |
| remove: (a) => { const i = list.findIndex(x => pyEquals(x, a[0])); if (i === -1) throw pyError("list.remove(x): x not in list"); list.splice(i, 1); return null; }, | |
| pop: (a) => { if (list.length === 0) throw pyError("pop from empty list"); return list.splice(a.length ? pyIndex(a[0], list.length) : list.length - 1, 1)[0]; }, | |
| index: (a) => { const i = list.findIndex(x => pyEquals(x, a[0])); if (i === -1) throw pyError(`${pyStr(a[0])} is not in list`); return i; }, | |
| count: (a) => list.filter(x => pyEquals(x, a[0])).length, | |
| sort: () => { list.sort((x, y) => { if (typeof x === "number" && typeof y === "number") return x - y; return pyOrder(x, y); }); return null; }, | |
| reverse: () => { list.reverse(); return null; }, | |
| clear: () => { list.length = 0; return null; }, | |
| copy: () => list.slice(), | |
| }; | |
| if (m[name] === undefined) throw pyError(`'list' object has no attribute '${name}'`); | |
| return (args, kwargs) => m[name](args, kwargs); | |
| } | |
| function pyDictMethod(d, name, I) { | |
| const m = { | |
| get: (a) => { const k = pyDictKey(a[0]); return d.m.has(k) ? d.m.get(k) : (a.length > 1 ? a[1] : null); }, | |
| keys: () => Array.from(d.m.keys()), | |
| values: () => Array.from(d.m.values()), | |
| items: () => Array.from(d.m.entries()).map(e => pyTuple([e[0], e[1]])), | |
| update: (a) => { | |
| const o = a[0]; | |
| if (o && o.__py === "dict") o.m.forEach((v, k) => d.m.set(k, v)); | |
| else if (o) for (const k in o) d.m.set(k, o[k]); | |
| return null; | |
| }, | |
| pop: (a) => { | |
| const k = pyDictKey(a[0]); | |
| if (!d.m.has(k)) { if (a.length > 1) return a[1]; throw pyError(`KeyError: ${pyStr(a[0])}`); } | |
| const v = d.m.get(k); | |
| d.m.delete(k); | |
| return v; | |
| }, | |
| popitem: () => { if (d.m.size === 0) throw pyError("popitem(): dictionary is empty"); const k = Array.from(d.m.keys()).pop(); const v = d.m.get(k); d.m.delete(k); return pyTuple([k, v]); }, | |
| clear: () => { d.m.clear(); return null; }, | |
| copy: () => { const nd = pyMakeDict(); d.m.forEach((v, k) => nd.m.set(k, v)); return nd; }, | |
| setdefault: (a) => { const k = pyDictKey(a[0]); if (!d.m.has(k)) d.m.set(k, a.length > 1 ? a[1] : null); return d.m.get(k); }, | |
| }; | |
| if (m[name] === undefined) throw pyError(`'dict' object has no attribute '${name}'`); | |
| return (args, kwargs) => m[name](args, kwargs); | |
| } | |
| // --- F-strings ------------------------------------------------------------ | |
| function pyEvalFString(raw, env, I) { | |
| let out = ""; | |
| let i = 0; | |
| while (i < raw.length) { | |
| const ch = raw[i]; | |
| if (ch === "{") { | |
| if (raw[i + 1] === "{") { out += "{"; i += 2; continue; } | |
| let depth = 1; | |
| let j = i + 1; | |
| let exprStr = ""; | |
| while (j < raw.length && depth > 0) { | |
| if (raw[j] === "{") depth++; | |
| else if (raw[j] === "}") depth--; | |
| if (depth > 0) exprStr += raw[j]; | |
| j++; | |
| } | |
| if (depth !== 0) throw pyError("f-string: unmatched '{'"); | |
| out += pyStr(pyEvalExprString(pyStripFormatSpec(exprStr), env, I)); | |
| i = j; | |
| } else if (ch === "}") { | |
| if (raw[i + 1] === "}") { out += "}"; i += 2; continue; } | |
| throw pyError("f-string: single '}' is not allowed"); | |
| } else { | |
| out += ch; | |
| i++; | |
| } | |
| } | |
| return out; | |
| } | |
| function pyEvalExprString(src, env, I) { | |
| const ast = pyParse(pyTokenize(src)); | |
| const exprStmts = ast.body.filter(s => s.type === "Expr"); | |
| if (exprStmts.length !== 1) throw pyError("f-string: invalid expression"); | |
| return pyEval(exprStmts[0].value, env, I); | |
| } | |
| // Strips an f-string format spec (everything after a top-level ':') so | |
| // expressions like {pi:.2f} evaluate as {pi}. Nested colons inside | |
| // brackets, parens, or quotes are preserved. | |
| function pyStripFormatSpec(src) { | |
| let depth = 0; | |
| let quote = null; | |
| for (let i = 0; i < src.length; i++) { | |
| const c = src[i]; | |
| if (quote) { | |
| if (c === "\\") i++; | |
| else if (c === quote) quote = null; | |
| continue; | |
| } | |
| if (c === '"' || c === "'") { quote = c; continue; } | |
| if (c === "(" || c === "[" || c === "{") depth++; | |
| else if (c === ")" || c === "]" || c === "}") depth--; | |
| else if (c === ":" && depth === 0) return src.slice(0, i); | |
| } | |
| return src; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment