diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 877c09b..18da3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,32 @@ jobs: run: npm run build:lib - name: Run type check - run: npm run build:types + run: npm run type-check - name: Run tests run: npm run test:run + + e2e: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + run: npx playwright test e2e/ + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} diff --git a/.github/workflows/node-compat.yml b/.github/workflows/node-compat.yml index 7c33a40..d8e56e2 100644 --- a/.github/workflows/node-compat.yml +++ b/.github/workflows/node-compat.yml @@ -16,6 +16,10 @@ on: - 'src/virtual-fs.ts' - '.github/workflows/node-compat.yml' +permissions: + contents: read + pull-requests: write + jobs: test: name: Node.js Compatibility diff --git a/.gitignore b/.gitignore index b63c879..f54113a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ npm-debug.log* # Temp folders for testing temp/ + +# Scratch files +e2e/deploy-debug.spec.ts +examples/macaly-demo.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc7fec..3333b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.14] - 2026-02-14 + +### Added +- **Agent Workbench demo**: AI coding agent that builds Next.js pages live with file editing, bash execution, and HMR preview. Added to homepage demos grid. +- **Vercel AI SDK demo**: Streaming AI chatbot with Next.js, OpenAI, and real-time token streaming via Pages Router API route +- **Express demo E2E tests**: New Playwright tests for the Express server demo +- **`vfs-require` module** (`src/frameworks/vfs-require.ts`): Shared require system extracted for reuse across entry points +- **`npm-serve` module** (`src/frameworks/npm-serve.ts`): Shared `/_npm/` package bundling endpoint with nested exports support +- **CI E2E pipeline**: GitHub Actions now runs Playwright E2E tests after unit tests with Chromium +- **CLAUDE.md**: Project instructions file for AI-assisted development + +### Fixed +- **Route group client-side navigation**: Pages inside route groups (e.g. `(marketing)/about`) now render correctly during client-side navigation. Replaced local path construction with server-based `resolveRoute()` using extended `/_next/route-info` endpoint that returns actual `page` and `layouts` paths. +- **`convertToModelMessages` import**: Vercel AI SDK demo now imports from `ai` package instead of non-existent `@ai-sdk/ui-utils` +- **npm-serve nested exports**: Packages with nested `exports` field entries (e.g. `ai/react`, `@ai-sdk/openai`) now resolve correctly +- **TypeScript type errors**: Fixed duplicate `setEnv` method, `executeApiHandler` return type, `cpExec` callback types + +### Changed +- **Agent Workbench guardrails removed**: AI agent can now modify any project file including root page (`/app/page.tsx`), `package.json`, and `tsconfig.json`. Only `/pages/api/chat.ts` remains protected. +- **E2E tests hardened**: Removed try/catch fallbacks across all E2E tests for strict assertions; collect page errors for better debugging +- **Convex and Vite demos refactored**: Use platform's `vfs-require` and `npm-serve` modules instead of inline implementations + +## [0.2.13] - 2026-02-12 + +### Added +- **Centralized CDN configuration** (`src/config/cdn.ts`): Single source of truth for esm.sh, unpkg, and other CDN URLs used across the codebase +- **esm.sh version resolution**: `redirectNpmImports` now reads `package.json` dependencies and includes the major version in esm.sh URLs (e.g. `ai@4/react`), fixing 404s on subpath imports +- **Setup overlay dialogs**: Convex and Vercel AI SDK demos now show an API key setup dialog on load with privacy notice ("your key stays in your browser") +- **New tests**: `tests/cdn-config.test.ts` (12 tests) and `tests/code-transforms.test.ts` (11 tests) + +### Changed +- Renamed AI chatbot demo files: `demo-ai-chatbot.html` → `demo-vercel-ai-sdk.html`, `ai-chatbot-demo.ts` → `vercel-ai-sdk-demo.ts` +- Replaced hardcoded CDN URLs throughout codebase with imports from `src/config/cdn.ts` + +### Removed +- **`sentry` shim** (`src/shims/sentry.ts`): Was a no-op stub for a non-existent Node.js built-in +- **Custom `convex` command** in `child_process.ts`: Convex now runs through the generic bin stub system like any other CLI tool +- **Convex-specific path remaps** in `fs.ts`: `path.resolve()` with correct `cwd` handles this generically +- **`vfs:` prefix stripping** in `fs.ts`: Moved to esbuild shim where the artifact originates + +## [0.2.12] - 2026-02-12 + +### Added + +- **Generic bin stubs:** `npm install` now reads each package's `bin` field and creates executable scripts in `/node_modules/.bin/`. CLI tools like `vitest`, `eslint`, `tsc`, etc. work automatically via the `node` command — no custom commands needed. +- **Streaming `container.run()` API:** Long-running commands support `onStdout`/`onStderr` callbacks and `AbortController` signal for cancellation. +- **`container.sendInput()`:** Send stdin data to running processes (emits both `data` and `keypress` events for readline compatibility). +- **Vitest demo with xterm.js:** New `examples/vitest-demo.html` showcasing real vitest execution in the browser with watch mode, syntax-highlighted terminal output, and file editing. +- **E2E tests for vitest demo:** 5 Playwright tests covering install, test execution, tab switching, failure detection, and watch mode restart. +- **`rollup` shim:** Stub module so vitest's dependency chain resolves without errors. +- **`fs.realpathSync.native`:** Added as alias for `realpathSync` (used by vitest internals). +- **`fs.createReadStream` / `fs.createWriteStream`:** Basic implementations using VirtualFS. +- **`path.delimiter` and `path.win32`:** Added missing path module properties. +- **`process.getuid()`, `process.getgid()`, `process.umask()`:** Added missing process methods used by npm packages. +- **`util.deprecate()`:** Returns the original function with a no-op deprecation warning. + +### Changed + +- **`Object.defineProperty` patch on `globalThis`:** Forces `configurable: true` for properties defined on `globalThis`, so libraries that define non-configurable globals (like vitest's `__vitest_index__`) can be re-run without errors. +- **VFS adapter executable mode:** Files in `/node_modules/.bin/` now return `0o755` mode so just-bash treats them as executable. +- **`Runtime.clearCache()` clears in-place:** Previously created a new empty object, leaving closures referencing the stale cache. Now deletes keys in-place. +- **Watch mode uses restart pattern:** Vitest caches modules internally (Vite's ModuleRunner), so file changes require a full vitest restart (abort + re-launch) rather than stdin-triggered re-runs. + +### Removed + +- **Custom vitest command:** Deleted `src/shims/vitest-command.ts` and removed vitest-specific handling from `child_process.ts`. Vitest now runs through the generic bin stub + `node` command like any other CLI tool. + ## [0.2.11] - 2026-02-09 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b3d2f5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# almostnode + +## What This Is + +almostnode is a **real competitor to WebContainers (StackBlitz)**. It runs Node.js natively in the browser — virtual filesystem, npm package installation, dev servers, the works. + +## Core Principle + +**Never write library-specific shim code. Fix the platform instead.** + +When a package doesn't work, the fix goes into the generic shims (fs, path, crypto, etc.), not into a package-specific adapter. Every demo should use real npm packages installed via `PackageManager`, served via `/_npm/` bundling, and running through the standard runtime. No CDN shortcuts, no manual protocol reimplementations, no fake adapters. + +## Architecture + +- **Runtime** (`src/runtime.ts`) — JS execution engine with `require()`, ESM-to-CJS transforms, 43 built-in module shims +- **VirtualFS** (`src/virtual-fs.ts`) — In-memory filesystem, exposed as `require('fs')` +- **PackageManager** (`src/npm/`) — Real npm packages downloaded, extracted, ESM-to-CJS transformed via esbuild-wasm +- **Service Worker** — Network interception for HTTP servers (`/__virtual__/{port}/`) +- **Dev Servers** — `NextDevServer` (Pages + App Router), `ViteDevServer` (React + HMR) +- **just-bash** — Bash emulator with custom commands (`node`, `npm`, `convex`) +- **Code Transforms** (`src/frameworks/code-transforms.ts`) — CSS Modules (css-tree AST), ESM-to-CJS (acorn AST), React Refresh, npm import redirect + +### Next.js Dev Server (split across files) + +- `src/frameworks/next-dev-server.ts` — Orchestrator (~1360 lines) +- `src/frameworks/next-route-resolver.ts` — Route resolution (~600 lines) +- `src/frameworks/next-api-handler.ts` — API route handlers (~350 lines) +- `src/frameworks/next-shims.ts` — Shim string constants (~1040 lines) +- `src/frameworks/next-html-generator.ts` — HTML page generation (~560 lines) +- `src/frameworks/next-config-parser.ts` — next.config.js parsing (AST + regex fallback) + +## Commands + +```bash +npm run dev # Vite dev server (port 5173) +npm run test:run # Unit tests (vitest, ~2250 tests, ~10s) +npm run test:e2e # E2E tests (playwright, ~105 tests) +npm run build # Build for production +``` + +## Testing + +- Unit tests: `tests/` directory, run with `npm run test:run` +- E2E tests: `e2e/` directory, run with `npx playwright test e2e/` +- Run a single E2E file: `npx playwright test e2e/vite-demo.spec.ts` +- Test harnesses live in `examples/` (HTML files with VFS setup) + +## Key Technical Details + +- **`/_npm/` endpoint**: Bundles npm packages from VFS as ESM for browser consumption via esbuild +- **`/_next/route-info`**: Server endpoint returning resolved route info (page, layouts, params) — used by client-side navigation +- **Virtual prefix**: `/__virtual__/{port}/` — all imports go through this for service worker interception +- **`isBrowser` flag**: In test env (jsdom), `isBrowser=false` — transforms run differently +- **ESM-to-CJS**: Happens both at install time (esbuild-wasm) and at runtime (in `loadModule()`) +- **Route groups**: `(groupName)` directories are transparent in URLs, resolved server-side + +## Where to Find More Context + +- **`README.md`** — Public API docs, usage examples, comparison with WebContainers, sandbox setup +- **`CHANGELOG.md`** — Version history and what changed +- **`examples/`** — Working demo HTML files (next-demo, vite-demo, express-demo, etc.) — read these to understand how the platform is used end-to-end +- **`e2e/`** — Playwright E2E tests that exercise each demo — read these to understand what each demo should do + +When working on a specific demo or feature, read the corresponding example HTML and E2E test first. + +## Release Process + +Always bump version in `package.json` and update `CHANGELOG.md` before pushing. Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format, Semantic Versioning. diff --git a/README.md b/README.md index cb8237e..51746e9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Built by the creators of [Macaly.com](https://macaly.com) — a tool that lets a - **Virtual File System** - Full in-memory filesystem with Node.js-compatible API - **Node.js API Shims** - 40+ shimmed modules (`fs`, `path`, `http`, `events`, and more) -- **npm Package Installation** - Install and run real npm packages in the browser +- **npm Package Installation** - Install and run real npm packages in the browser with automatic bin stub creation +- **Run Any CLI Tool** - npm packages with `bin` entries (vitest, eslint, tsc, etc.) work automatically - **Dev Servers** - Built-in Vite and Next.js development servers - **Hot Module Replacement** - React Refresh support for instant updates - **TypeScript Support** - First-class TypeScript/TSX transformation via esbuild-wasm @@ -127,6 +128,69 @@ container.execute(` // Output: Hello world ``` +### Running Shell Commands + +```typescript +import { createContainer } from 'almostnode'; + +const container = createContainer(); + +// Write a package.json with scripts +container.vfs.writeFileSync('/package.json', JSON.stringify({ + name: 'my-app', + scripts: { + build: 'echo Building...', + test: 'vitest run' + } +})); + +// Run shell commands directly +const result = await container.run('npm run build'); +console.log(result.stdout); // "Building..." + +await container.run('npm test'); +await container.run('echo hello && echo world'); +await container.run('ls /'); +``` + +Supported npm commands: `npm run + + diff --git a/examples/demo-convex-app.html b/examples/demo-convex-app.html index e02d8ed..96a71bc 100644 --- a/examples/demo-convex-app.html +++ b/examples/demo-convex-app.html @@ -142,6 +142,71 @@ margin-bottom: 12px; } @keyframes spin { to { transform: rotate(360deg); } } + .setup-overlay { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + } + .setup-overlay.hidden { display: none; } + .setup-dialog { + background: var(--surface); + border: 1px solid var(--border); + padding: 32px; + max-width: 420px; + width: 90%; + } + .setup-dialog h2 { + font-family: var(--mono); + font-size: 0.9rem; + color: var(--text-bright); + margin-bottom: 8px; + } + .setup-dialog p { + font-size: 0.8rem; + color: var(--text-dim); + line-height: 1.6; + margin-bottom: 16px; + } + .setup-dialog .privacy-note { + font-family: var(--mono); + font-size: 0.65rem; + color: var(--accent-dim); + background: var(--accent-bg); + border: 1px solid var(--accent-border); + padding: 8px 12px; + margin-bottom: 16px; + line-height: 1.5; + } + .setup-dialog input { + width: 100%; + padding: 10px 12px; + font-family: var(--mono); + font-size: 0.78rem; + border: 1px solid var(--border); + background: var(--black); + color: var(--text); + margin-bottom: 12px; + } + .setup-dialog input::placeholder { color: var(--text-dim); } + .setup-dialog button { + width: 100%; + padding: 10px; + font-family: var(--mono); + font-size: 0.78rem; + font-weight: 600; + background: var(--accent); + color: var(--black); + border: none; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .setup-dialog button:hover { background: #00ffaa; } + .setup-dialog button:disabled { opacity: 0.4; cursor: not-allowed; } @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } .file-tree-panel { display: none; } @@ -160,7 +225,16 @@ next.js · convex · realtime -
+
+
+
+

Convex Deploy Key Required

+

This demo deploys functions to Convex cloud and syncs data in real time. Enter your deploy key to get started.

+
Your key stays in your browser. It is used directly to communicate with the Convex API — it is never sent to our servers.
+ + +
+
diff --git a/examples/demo-ai-chatbot.html b/examples/demo-vercel-ai-sdk.html similarity index 59% rename from examples/demo-ai-chatbot.html rename to examples/demo-vercel-ai-sdk.html index 8b2adf6..10165ba 100644 --- a/examples/demo-ai-chatbot.html +++ b/examples/demo-vercel-ai-sdk.html @@ -3,7 +3,7 @@ - AI Chatbot Demo — almostnode agent runtime + Vercel AI SDK Demo — almostnode agent runtime + + +
+ ← demos + / + + / + npm Scripts + npm run · package.json +
+ +
+
+
+ package.json +
+
+ Ready +
+
+ +
+ +
+
+ Terminal +
+
+
+ $ + +
+
+ Try: npm run build npm test npm start npm run ls / echo hello +
+
+
+ + + + diff --git a/examples/vitest-demo.html b/examples/vitest-demo.html new file mode 100644 index 0000000..0ae7135 --- /dev/null +++ b/examples/vitest-demo.html @@ -0,0 +1,97 @@ + + + + + + Vitest Testing Demo — almostnode + + + + +
+ ← demos + / + + / + Vitest Testing + vitest · npm run test +
+ +
+
+
+ Editor +
+ +
+ Initializing... +
+
+
+
utils.js
+
utils.test.js
+
package.json
+
+ +
+ +
+
+ Terminal + +
+
+
+
+ + + + diff --git a/index.html b/index.html index 57e623e..8e0aac0 100644 --- a/index.html +++ b/index.html @@ -1350,6 +1350,36 @@

Express Server

express · npm install + +

npm Scripts

+

Interactive terminal to run package.json scripts with npm run, lifecycle hooks, and bash.

+ npm run · bash · terminal +
+ + +

Vitest Testing

+

Run real vitest unit tests in the browser with npm run test — using @vitest/expect assertions.

+ vitest · npm run test +
+ + +

Convex Todo App

+

Real-time todo list with Next.js and Convex — deploy and sync from the browser.

+ next.js · convex +
+ + +

Vercel AI SDK

+

Streaming AI chatbot with Next.js and OpenAI — real-time token streaming.

+ next.js · ai sdk · openai +
+ + +

Agent Workbench

+

AI coding agent that builds Next.js pages live — with file editing, bash, and HMR preview.

+ ai agent · tool-calling · next.js +
+
diff --git a/package-lock.json b/package-lock.json index f8e6d90..9612856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,21 @@ { "name": "almostnode", - "version": "0.2.6", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "almostnode", - "version": "0.2.6", + "version": "0.2.13", "license": "MIT", "dependencies": { + "@ai-sdk/openai": "^3.0.28", + "@ai-sdk/react": "^3.0.87", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", + "ai": "^6.0.85", "brotli": "^1.3.3", "brotli-wasm": "^3.0.1", "comlink": "^4.4.2", @@ -19,15 +24,18 @@ "pako": "^2.1.0", "resolve.exports": "^2.0.3", "vite-plugin-top-level-await": "^1.6.0", - "vite-plugin-wasm": "^3.5.0" + "vite-plugin-wasm": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@playwright/test": "^1.58.0", "@types/css-tree": "^2.3.11", "@types/node": "^25.0.10", "@types/pako": "^2.0.4", + "dotenv": "^17.3.1", "esbuild": "^0.27.2", "jsdom": "^27.4.0", + "react-dom": "^19.2.4", "typescript": "^5.9.3", "vite": "^5.4.0", "vitest": "^4.0.18" @@ -43,6 +51,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.45", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.45.tgz", + "integrity": "sha512-ZB6kHV+D8mLCRnkpWotLCV/rZK4NiODxx4Kv7JdT9QmQknbG/scbE4iyoT4JLFdULA8Y/IVbMvyE0Nwq3Dceqw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.28.tgz", + "integrity": "sha512-m2Dm6fwUzMksqnPrd5f/WZ4cZ9GTZHpzsVO6jxKQwwc84gFHzAFZmUCG0C5mV7XlPOw4mwaiYV3HfLiEfphvvA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", + "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.87", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.87.tgz", + "integrity": "sha512-qa4Ywm08g27Voys1xuF2WeX3s8shd4hLJCCxi/Ws6cUZsWpMnFW2rtEfCcKRlWyJ4NRypauiNmcvQKz4v6u0/A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.15", + "ai": "6.0.85", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", @@ -728,6 +816,15 @@ "node": ">= 20.19.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.58.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", @@ -1090,7 +1187,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@swc/core": { @@ -1383,6 +1479,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1467,6 +1572,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1498,6 +1618,24 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.85", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.85.tgz", + "integrity": "sha512-2bP7M+OcNQGSIH8I3jdujUadxj4tAwuHBvLhpmDSlcjRXXry3zNGEajjjRraOjObHMO/Yqa37PJWhPVHIHt2TQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.45", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", @@ -1748,6 +1886,15 @@ "node": ">=4.0.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1766,6 +1913,19 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1847,6 +2007,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2075,6 +2244,12 @@ } } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/just-bash": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-2.7.0.tgz", @@ -2492,6 +2667,29 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2602,6 +2800,13 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2759,6 +2964,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/swr": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz", + "integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2794,6 +3012,18 @@ "node": ">=6" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2956,6 +3186,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3766,6 +4005,15 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a31b95e..8fbd9d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "almostnode", - "version": "0.2.11", + "version": "0.2.14", "description": "Node.js in your browser. Just like that.", "type": "module", "license": "MIT", @@ -81,11 +81,17 @@ "test:e2e:debug": "playwright test --debug", "sandbox": "vite --port 3002 --config vite.sandbox.config.js", "sandbox:demo": "echo 'Run in two terminals:\\n Terminal 1: npm run sandbox (serves sandbox on port 3002)\\n Terminal 2: npm run dev (serves main app on port 5173)\\nThen open: http://localhost:5173/examples/sandbox-next-demo.html'", + "type-check": "tsc --noEmit", "prepublishOnly": "npm run test:run && npm run build:publish" }, "dependencies": { + "@ai-sdk/openai": "^3.0.28", + "@ai-sdk/react": "^3.0.87", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", + "ai": "^6.0.85", "brotli": "^1.3.3", "brotli-wasm": "^3.0.1", "comlink": "^4.4.2", @@ -94,15 +100,18 @@ "pako": "^2.1.0", "resolve.exports": "^2.0.3", "vite-plugin-top-level-await": "^1.6.0", - "vite-plugin-wasm": "^3.5.0" + "vite-plugin-wasm": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@playwright/test": "^1.58.0", "@types/css-tree": "^2.3.11", "@types/node": "^25.0.10", "@types/pako": "^2.0.4", + "dotenv": "^17.3.1", "esbuild": "^0.27.2", "jsdom": "^27.4.0", + "react-dom": "^19.2.4", "typescript": "^5.9.3", "vite": "^5.4.0", "vitest": "^4.0.18" diff --git a/playwright.config.ts b/playwright.config.ts index 0133430..2a9b521 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { defineConfig } from '@playwright/test'; export default defineConfig({ @@ -10,12 +11,20 @@ export default defineConfig({ screenshot: 'only-on-failure', trace: 'on-first-retry', }, - webServer: { - command: 'npm run dev', - url: 'http://localhost:5173/examples/vite-demo.html', - reuseExistingServer: !process.env.CI, - timeout: 30000, - }, + webServer: [ + { + command: 'npm run dev', + url: 'http://localhost:5173/examples/vite-demo.html', + reuseExistingServer: !process.env.CI, + timeout: 30000, + }, + { + command: 'node e2e/cors-proxy-server.mjs', + url: 'http://localhost:8787', + reuseExistingServer: !process.env.CI, + timeout: 10000, + }, + ], projects: [ { name: 'chromium', diff --git a/src/agent-workbench-entry.ts b/src/agent-workbench-entry.ts new file mode 100644 index 0000000..5690d18 --- /dev/null +++ b/src/agent-workbench-entry.ts @@ -0,0 +1,199 @@ +/** + * Agent Workbench Entry Point + * + * Architecture: + * - The entire app (chat UI + preview) runs inside almostnode's virtual Next.js + * - Chat page (/app/page.tsx) uses useChat from @ai-sdk/react (loaded from esm.sh) + * - API route (/pages/api/chat.ts) uses streamText with tool-calling + * - AI SDK packages (ai, @ai-sdk/openai, zod) are installed via PackageManager + * - Tools operate on VFS directly (read, write, replace, list, bash) + */ + +import { VirtualFS } from './virtual-fs'; +import { NextDevServer } from './frameworks/next-dev-server'; +import { getServerBridge } from './server-bridge'; +import { createAgentWorkbenchProject } from './agent-workbench-project'; +import { initChildProcess, exec as cpExec } from './shims/child_process'; +import { PackageManager } from './npm/index'; + +// ── Constants ── + +const CORS_PROXY = new URLSearchParams(window.location.search).get('corsProxy') || 'https://almostnode-cors-proxy.langtail.workers.dev/?url='; +const PORT = 3004; + +// ── Logging (outside React) ── + +const logsEl = document.getElementById('logs') as HTMLDivElement; + +function log(message: string, type: 'info' | 'error' | 'warn' | 'success' = 'info') { + const line = document.createElement('div'); + const time = new Date().toLocaleTimeString(); + line.textContent = `[${time}] ${message}`; + line.className = type; + logsEl.appendChild(line); + logsEl.scrollTop = logsEl.scrollHeight; +} + +// ── Create __project__ module (VFS operations for the API route) ── + +function createProjectModule(vfs: VirtualFS) { + return { + readFile: (path: string) => vfs.readFileSync(path, 'utf8') as string, + writeFile: (path: string, content: string) => vfs.writeFileSync(path, content), + existsSync: (path: string) => vfs.existsSync(path), + listFiles: (dir: string) => vfs.readdirSync(dir), + statSync: (path: string) => vfs.statSync(path), + mkdirSync: (dir: string, opts?: { recursive?: boolean }) => vfs.mkdirSync(dir, opts), + runCommand: (command: string): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve('Error: Command timed out (10s)'); + }, 10000); + cpExec(command, { cwd: '/' }, (error, stdout, stderr) => { + clearTimeout(timeout); + if (error) { + resolve(stderr ? `Error: ${stderr}` : `Error: ${error.message}`); + } else { + const output = (stdout || '') + (stderr ? `\n[stderr] ${stderr}` : ''); + resolve(output || '(no output)'); + } + }); + }); + }, + log: (msg: string) => log(msg, 'success'), + }; +} + +// ── Bootstrap ── + +async function main() { + try { + log('Creating virtual file system...'); + const vfs = new VirtualFS(); + + log('Setting up starter project...'); + createAgentWorkbenchProject(vfs); + initChildProcess(vfs); + log('Project files created', 'success'); + + // Install AI SDK packages via PackageManager + log('Installing npm packages...'); + const pm = new PackageManager(vfs, { cwd: '/' }); + + // Install zod v4 (provides both zod/v3 and zod/v4 sub-paths needed by + // @ai-sdk/provider-utils). The AI SDK server-side code runs in VFS so it + // uses the real npm-installed zod, not esm.sh. + // @ai-sdk/react is installed locally and served via /_npm/ (not esm.sh) + // to avoid esm.sh resolution bugs with zod/v4 sub-path exports. + const packages = ['zod', 'ai@5', '@ai-sdk/openai@2', '@ai-sdk/react@2']; + for (const pkg of packages) { + log(`Installing ${pkg}...`); + await pm.install(pkg, { + onProgress: (msg) => log(msg), + transform: true, + }); + } + log('All packages installed', 'success'); + + log('Starting Next.js dev server...'); + + const projectModule = createProjectModule(vfs); + + const apiModules: Record = { + '__project__': projectModule, + }; + + const devServer = new NextDevServer(vfs, { + port: PORT, + root: '/', + preferAppRouter: true, + apiModules, + corsProxy: CORS_PROXY, + }); + + const bridge = getServerBridge(); + + try { + log('Initializing Service Worker...'); + await bridge.initServiceWorker(); + log('Service Worker ready', 'success'); + } catch (error) { + log(`Service Worker warning: ${error}`, 'warn'); + } + + bridge.registerServer(devServer as any, PORT); + devServer.start(); + + const serverUrl = bridge.getServerUrl(PORT) + '/'; + log(`Server running at: ${serverUrl}`, 'success'); + + // Create preview iframe + const previewContainer = document.getElementById('previewContainer') as HTMLDivElement; + previewContainer.innerHTML = ''; + const iframe = document.createElement('iframe'); + iframe.src = serverUrl; + iframe.id = 'preview-iframe'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + iframe.setAttribute( + 'sandbox', + 'allow-forms allow-scripts allow-same-origin allow-popups allow-pointer-lock allow-modals' + ); + + iframe.onload = () => { + if (iframe?.contentWindow && devServer) { + devServer.setHMRTarget(iframe.contentWindow); + } + }; + + previewContainer.appendChild(iframe); + + // Setup overlay handlers + const setupOverlay = document.getElementById('setupOverlay') as HTMLDivElement; + const setupKeyInput = document.getElementById('setupKeyInput') as HTMLInputElement; + const setupKeyBtn = document.getElementById('setupKeyBtn') as HTMLButtonElement; + + setupKeyInput.oninput = () => { + setupKeyBtn.disabled = !setupKeyInput.value.trim(); + }; + + const startAgent = (key: string) => { + const sanitizedKey = key.trim().replace(/[^\x00-\x7F]/g, ''); + if (!sanitizedKey) { + log('Please enter an API key', 'error'); + return; + } + if (!sanitizedKey.startsWith('sk-')) { + log('Warning: OpenAI keys typically start with "sk-"', 'warn'); + } + + // Pass API key to the virtual environment via env vars. + // The API route reads process.env.OPENAI_API_KEY and configures the + // CORS proxy itself — no need to inject pre-configured modules. + devServer.setEnv('OPENAI_API_KEY', sanitizedKey); + + setupOverlay.classList.add('hidden'); + log('Agent ready — enter a message in the chat', 'success'); + }; + + setupKeyBtn.onclick = () => { + startAgent(setupKeyInput.value); + }; + + setupKeyInput.onkeydown = (e) => { + if (e.key === 'Enter' && setupKeyInput.value.trim()) { + startAgent(setupKeyInput.value); + } + }; + + log('Workbench ready!', 'success'); + log('Enter your OpenAI API key to start.'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log(`Error: ${errorMessage}`, 'error'); + console.error(error); + } +} + +main(); diff --git a/src/agent-workbench-project.ts b/src/agent-workbench-project.ts new file mode 100644 index 0000000..c9c07b7 --- /dev/null +++ b/src/agent-workbench-project.ts @@ -0,0 +1,455 @@ +/** + * Agent Workbench - Virtual Project Seed + * + * Creates a Next.js project in VFS with: + * - Chat UI using useChat from @ai-sdk/react (App Router client page, loaded from esm.sh) + * - API route using streamText + Pages Router streaming (server, proven pattern) + * - Tools: read_file, write_file, replace_in_file, list_files, run_bash + */ + +import { VirtualFS } from './virtual-fs'; + +const PACKAGE_JSON = { + name: 'agent-workbench-app', + version: '0.1.0', + private: true, + scripts: { + dev: 'next dev', + build: 'next build', + start: 'next start', + }, + dependencies: { + next: '^14.0.0', + react: '^18.2.0', + 'react-dom': '^18.2.0', + ai: '^6.0.0', + '@ai-sdk/react': '^3.0.0', + }, +}; + +// ── API Route (/pages/api/chat.ts) — Pages Router for proven streaming ── + +const API_ROUTE = `import { streamText, tool, stepCountIs, convertToModelMessages } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import { z } from 'zod'; +import { readFile, writeFile, existsSync, listFiles, statSync, mkdirSync, runCommand, log } from '__project__'; + +var CORS_PROXY = process.env.CORS_PROXY_URL || 'https://almostnode-cors-proxy.langtail.workers.dev/?url='; + +var openai = createOpenAI({ + apiKey: process.env.OPENAI_API_KEY || '', + fetch: function(url, init) { + var proxiedUrl = CORS_PROXY + encodeURIComponent(String(url)); + return globalThis.fetch(proxiedUrl, init); + }, +}); + +var SYSTEM_PROMPT = 'You are a frontend developer agent. You help users build and modify a Next.js App Router application running in the browser.\\n\\nAvailable tools:\\n- read_file: Read file contents at a given path\\n- write_file: Create or overwrite a file (parent directories are created automatically)\\n- replace_in_file: Make a targeted text replacement in a file (first occurrence)\\n- list_files: List files and directories at a path\\n- run_bash: Run a shell command (e.g. ls, cat, echo, node scripts)\\n\\nThe project uses Next.js App Router. Current structure:\\n- /app/layout.tsx — Root layout\\n- /app/page.tsx — Root page (the chat UI, but you can replace it)\\n- /public/ — Static assets\\n- /package.json — Project config\\n\\nGuidelines:\\n- You can modify ANY file in the project, including the root page (/app/page.tsx) and layout\\n- The only protected file is /pages/api/chat.ts (the agent API route)\\n- Create new pages under /app/ (e.g. /app/about/page.tsx, /app/dashboard/page.tsx)\\n- After creating a page, tell the user to type the path (e.g. /about) in the preview URL bar and click Go\\n- Use inline styles for styling\\n- Write clean, modern React (JSX/TSX) code\\n- Keep responses concise — explain what you did briefly'; + +function validatePath(path, isWrite) { + if (!path.startsWith('/')) return 'Path must be absolute (start with /)'; + if (path.includes('..')) return 'Path must not contain ..'; + if (path.startsWith('/node_modules')) return 'Cannot access /node_modules'; + if (isWrite && path === '/pages/api/chat.ts') return 'Cannot modify the agent API route'; + return null; +} + +var agentTools = { + read_file: tool({ + description: 'Read the contents of a file at the given path', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file (e.g. /app/page.tsx)'), + }), + execute: async function(args) { + var err = validatePath(args.path, false); + if (err) return 'Error: ' + err; + if (!existsSync(args.path)) return 'Error: File not found: ' + args.path; + return readFile(args.path); + }, + }), + + write_file: tool({ + description: 'Write content to a file. Creates the file if it does not exist, or overwrites it. Parent directories are created automatically.', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file'), + content: z.string().describe('Full file content to write'), + }), + execute: async function(args) { + var err = validatePath(args.path, true); + if (err) return 'Error: ' + err; + if (args.content.length > 50000) return 'Error: File content too large (max 50KB)'; + var dir = args.path.substring(0, args.path.lastIndexOf('/')); + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFile(args.path, args.content); + log('File written: ' + args.path + ' (' + args.content.length + ' chars)'); + return 'File written successfully'; + }, + }), + + replace_in_file: tool({ + description: 'Replace the first occurrence of old_text with new_text in a file. Use this for targeted edits instead of rewriting the whole file.', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file'), + old_text: z.string().describe('Exact text to find in the file'), + new_text: z.string().describe('Replacement text'), + }), + execute: async function(args) { + var err = validatePath(args.path, true); + if (err) return 'Error: ' + err; + if (!existsSync(args.path)) return 'Error: File not found: ' + args.path; + var fileContent = readFile(args.path); + if (!fileContent.includes(args.old_text)) return 'Error: old_text not found in file'; + var newContent = fileContent.replace(args.old_text, args.new_text); + writeFile(args.path, newContent); + log('File edited: ' + args.path); + return 'Replacement made successfully'; + }, + }), + + list_files: tool({ + description: 'List files and directories at the given path. Directories end with /', + inputSchema: z.object({ + path: z.string().describe('Directory path to list (e.g. / or /app)'), + }), + execute: async function(args) { + var err = validatePath(args.path, false); + if (err) return 'Error: ' + err; + if (!existsSync(args.path)) return 'Error: Directory not found: ' + args.path; + var entries = listFiles(args.path); + var result = entries.map(function(entry) { + var fullPath = args.path.endsWith('/') ? args.path + entry : args.path + '/' + entry; + try { + var stat = statSync(fullPath); + return stat.isDirectory() ? entry + '/' : entry; + } catch (e) { + return entry; + } + }); + return result.join('\\n') || '(empty directory)'; + }, + }), + + run_bash: tool({ + description: 'Run a shell command in the virtual environment. Supports basic commands like ls, cat, echo, mkdir, cp, mv, node. Output is captured and returned.', + inputSchema: z.object({ + command: z.string().describe('The shell command to run (e.g. "ls -la /app")'), + }), + execute: async function(args) { + if (!args.command) return 'Error: No command provided'; + log('Bash: ' + args.command); + var result = await runCommand(args.command); + return result; + }, + }), +}; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + var uiMessages = req.body.messages; + if (!uiMessages || !Array.isArray(uiMessages)) { + return res.status(400).json({ error: 'Invalid messages format' }); + } + + var messages = await convertToModelMessages(uiMessages); + + var result = streamText({ + model: openai('gpt-4.1'), + system: SYSTEM_PROMPT, + messages: messages, + tools: agentTools, + stopWhen: stepCountIs(15), + onStepFinish: function(step) { + if (step.toolCalls && step.toolCalls.length > 0) { + log(step.toolCalls.map(function(tc) { return tc.toolName; }).join(', ') + ' (' + (step.usage.totalTokens || 0) + ' tokens)'); + } + }, + onError: function(info) { + log('Stream error: ' + info.error); + }, + }); + + return result.toUIMessageStreamResponse(); + } catch (error) { + log('API error: ' + (error && error.message ? error.message : String(error))); + if (!res.headersSent) { + res.status(500).json({ error: error && error.message ? error.message : 'Internal server error' }); + } + } +} +`; + +// ── Page (/app/page.tsx) — Chat UI with embedded preview ── +// This runs entirely inside the virtual Next.js via esm.sh imports. + +const PAGE = `'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { useChat } from '@ai-sdk/react'; + +function formatToolArgs(toolName, args) { + if (!args) return ''; + if (toolName === 'write_file') return args.path + ' (' + (args.content || '').length + ' chars)'; + if (toolName === 'run_bash') return args.command || ''; + if (toolName === 'replace_in_file') return args.path || ''; + return args.path || JSON.stringify(args).slice(0, 120); +} + +export default function AgentWorkbench() { + var loc = typeof window !== 'undefined' ? window.location : null; + var basePath = loc + ? (loc.pathname.endsWith('/') ? loc.pathname.slice(0, -1) : loc.pathname) + : ''; + + var [input, setInput] = useState(''); + var [pathInput, setPathInput] = useState('/welcome'); + var [previewSrc, setPreviewSrc] = useState(basePath + '/welcome'); + var bottomRef = useRef(null); + var iframeRef = useRef(null); + + var { messages, sendMessage, status, error } = useChat({ + api: basePath + '/api/chat', + }); + + var isLoading = status === 'submitted' || status === 'streaming'; + + useEffect(function() { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Relay HMR messages from parent window to preview iframe + useEffect(function() { + function onMessage(event) { + if (event.data && event.data.channel === 'next-hmr' && iframeRef.current && iframeRef.current.contentWindow) { + try { iframeRef.current.contentWindow.postMessage(event.data, '*'); } catch(e) {} + } + } + window.addEventListener('message', onMessage); + return function() { window.removeEventListener('message', onMessage); }; + }, []); + + function handleSubmit(e) { + e.preventDefault(); + if (!input.trim() || isLoading) return; + sendMessage({ text: input }); + setInput(''); + } + + function navigatePreview(path) { + if (!path) return; + var p = path.startsWith('/') ? path : '/' + path; + setPathInput(p); + setPreviewSrc(basePath + p); + } + + return ( +
+ {/* Chat panel */} +
+ {/* Header */} +
+ Agent Chat + gpt-4.1 +
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+

What do you want to build?

+

I can create pages, components, and layouts. The preview updates live via HMR.

+
+ )} + + {messages.map(function(m) { + return ( +
+ {m.role === 'user' ? ( +
+ {m.parts && m.parts.filter(function(p) { return p.type === 'text'; }).map(function(p, i) { + return {p.text}; + })} +
+ ) : ( +
+ {m.parts && m.parts.map(function(part, i) { + if (part.type === 'text' && part.text) { + return ( +
+ {part.text} +
+ ); + } + var toolMatch = part.type && part.type.match(/^tool-(.+)$/); + if (toolMatch) { + var toolName = toolMatch[1]; + var done = part.state === 'result'; + return ( +
+
{toolName}
+
{formatToolArgs(toolName, part.args)}
+ {done &&
{String(part.result).slice(0, 200)}
} +
+ ); + } + return null; + })} +
+ )} +
+ ); + })} + + {isLoading && messages.length > 0 && ( +
Thinking...
+ )} + + {error && ( +
{error.message}
+ )} + +
+
+ + {/* Input */} +
+ + +
+
+ + {/* Preview panel */} +
+ {/* Preview URL bar */} +
+ Preview + + + +
+ + {/* Preview content */} +
+