Add v2 sandbox implementation with new API routes and sandbox library
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { SandboxFactory } from '@/lib/sandbox/factory';
|
||||||
|
import { SandboxProvider } from '@/lib/sandbox/types';
|
||||||
|
import type { SandboxState } from '@/types/sandbox';
|
||||||
|
|
||||||
|
// Store active sandbox globally
|
||||||
|
declare global {
|
||||||
|
var activeSandboxProvider: SandboxProvider | null;
|
||||||
|
var sandboxData: any;
|
||||||
|
var existingFiles: Set<string>;
|
||||||
|
var sandboxState: SandboxState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
console.log('[create-ai-sandbox-v2] Creating sandbox...');
|
||||||
|
|
||||||
|
// Clean up existing sandbox if any
|
||||||
|
if (global.activeSandboxProvider) {
|
||||||
|
console.log('[create-ai-sandbox-v2] Terminating existing sandbox...');
|
||||||
|
try {
|
||||||
|
await global.activeSandboxProvider.terminate();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to terminate existing sandbox:', e);
|
||||||
|
}
|
||||||
|
global.activeSandboxProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing files tracking
|
||||||
|
if (global.existingFiles) {
|
||||||
|
global.existingFiles.clear();
|
||||||
|
} else {
|
||||||
|
global.existingFiles = new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new sandbox using factory
|
||||||
|
const provider = SandboxFactory.create();
|
||||||
|
const sandboxInfo = await provider.createSandbox();
|
||||||
|
|
||||||
|
console.log('[create-ai-sandbox-v2] Setting up Vite React app...');
|
||||||
|
await provider.setupViteApp();
|
||||||
|
|
||||||
|
// Store provider globally
|
||||||
|
global.activeSandboxProvider = provider;
|
||||||
|
global.sandboxData = {
|
||||||
|
sandboxId: sandboxInfo.sandboxId,
|
||||||
|
url: sandboxInfo.url
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize sandbox state
|
||||||
|
global.sandboxState = {
|
||||||
|
fileCache: {
|
||||||
|
files: {},
|
||||||
|
lastSync: Date.now(),
|
||||||
|
sandboxId: sandboxInfo.sandboxId
|
||||||
|
},
|
||||||
|
sandbox: provider, // Store the provider instead of raw sandbox
|
||||||
|
sandboxData: {
|
||||||
|
sandboxId: sandboxInfo.sandboxId,
|
||||||
|
url: sandboxInfo.url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[create-ai-sandbox-v2] Sandbox ready at:', sandboxInfo.url);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sandboxId: sandboxInfo.sandboxId,
|
||||||
|
url: sandboxInfo.url,
|
||||||
|
provider: sandboxInfo.provider,
|
||||||
|
message: 'Sandbox created and Vite React app initialized'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[create-ai-sandbox-v2] Error:', error);
|
||||||
|
|
||||||
|
// Clean up on error
|
||||||
|
if (global.activeSandboxProvider) {
|
||||||
|
try {
|
||||||
|
await global.activeSandboxProvider.terminate();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to terminate sandbox on error:', e);
|
||||||
|
}
|
||||||
|
global.activeSandboxProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create sandbox',
|
||||||
|
details: error instanceof Error ? error.stack : undefined
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { SandboxProvider } from '@/lib/sandbox/types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var activeSandboxProvider: SandboxProvider | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { packages } = await request.json();
|
||||||
|
|
||||||
|
if (!packages || !Array.isArray(packages) || packages.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Packages array is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.activeSandboxProvider) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'No active sandbox'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[install-packages-v2] Installing: ${packages.join(', ')}`);
|
||||||
|
|
||||||
|
const result = await global.activeSandboxProvider.installPackages(packages);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
output: result.stdout,
|
||||||
|
error: result.stderr,
|
||||||
|
message: result.success ? 'Packages installed successfully' : 'Package installation failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[install-packages-v2] Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { SandboxProvider } from '@/lib/sandbox/types';
|
||||||
|
|
||||||
|
// Get active sandbox provider from global state
|
||||||
|
declare global {
|
||||||
|
var activeSandboxProvider: SandboxProvider | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { command } = await request.json();
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Command is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.activeSandboxProvider) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'No active sandbox'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[run-command-v2] Executing: ${command}`);
|
||||||
|
|
||||||
|
const result = await global.activeSandboxProvider.runCommand(command);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
output: result.stdout,
|
||||||
|
error: result.stderr,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
message: result.success ? 'Command executed successfully' : 'Command failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[run-command-v2] Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { SandboxProvider, SandboxProviderConfig } from './types';
|
||||||
|
import { E2BProvider } from './providers/e2b-provider';
|
||||||
|
import { VercelProvider } from './providers/vercel-provider';
|
||||||
|
|
||||||
|
export class SandboxFactory {
|
||||||
|
static create(provider?: string, config?: SandboxProviderConfig): SandboxProvider {
|
||||||
|
// Use environment variable if provider not specified
|
||||||
|
const selectedProvider = provider || process.env.SANDBOX_PROVIDER || 'e2b';
|
||||||
|
|
||||||
|
console.log(`[SandboxFactory] Creating ${selectedProvider} provider`);
|
||||||
|
|
||||||
|
switch (selectedProvider.toLowerCase()) {
|
||||||
|
case 'e2b':
|
||||||
|
return new E2BProvider(config || {});
|
||||||
|
|
||||||
|
case 'vercel':
|
||||||
|
return new VercelProvider(config || {});
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown sandbox provider: ${selectedProvider}. Supported providers: e2b, vercel`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAvailableProviders(): string[] {
|
||||||
|
return ['e2b', 'vercel'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static isProviderAvailable(provider: string): boolean {
|
||||||
|
switch (provider.toLowerCase()) {
|
||||||
|
case 'e2b':
|
||||||
|
return !!process.env.E2B_API_KEY;
|
||||||
|
|
||||||
|
case 'vercel':
|
||||||
|
// Vercel can use OIDC (automatic) or PAT
|
||||||
|
return !!process.env.VERCEL_OIDC_TOKEN ||
|
||||||
|
(!!process.env.VERCEL_TOKEN && !!process.env.VERCEL_TEAM_ID && !!process.env.VERCEL_PROJECT_ID);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import { Sandbox } from '@e2b/code-interpreter';
|
||||||
|
import { SandboxProvider, SandboxInfo, CommandResult, SandboxProviderConfig } from '../types';
|
||||||
|
import { appConfig } from '@/config/app.config';
|
||||||
|
|
||||||
|
export class E2BProvider extends SandboxProvider {
|
||||||
|
private existingFiles: Set<string> = new Set();
|
||||||
|
|
||||||
|
async createSandbox(): Promise<SandboxInfo> {
|
||||||
|
try {
|
||||||
|
console.log('[E2BProvider] Creating sandbox...');
|
||||||
|
|
||||||
|
// Kill existing sandbox if any
|
||||||
|
if (this.sandbox) {
|
||||||
|
console.log('[E2BProvider] Killing existing sandbox...');
|
||||||
|
try {
|
||||||
|
await this.sandbox.kill();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to close existing sandbox:', e);
|
||||||
|
}
|
||||||
|
this.sandbox = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing files tracking
|
||||||
|
this.existingFiles.clear();
|
||||||
|
|
||||||
|
// Create base sandbox
|
||||||
|
console.log(`[E2BProvider] Creating E2B sandbox with ${appConfig.e2b.timeoutMinutes} minute timeout...`);
|
||||||
|
this.sandbox = await Sandbox.create({
|
||||||
|
apiKey: this.config.e2b?.apiKey || process.env.E2B_API_KEY,
|
||||||
|
timeoutMs: this.config.e2b?.timeoutMs || appConfig.e2b.timeoutMs
|
||||||
|
});
|
||||||
|
|
||||||
|
const sandboxId = (this.sandbox as any).sandboxId || Date.now().toString();
|
||||||
|
const host = (this.sandbox as any).getHost(appConfig.e2b.vitePort);
|
||||||
|
|
||||||
|
console.log(`[E2BProvider] Sandbox created: ${sandboxId}`);
|
||||||
|
console.log(`[E2BProvider] Sandbox host: ${host}`);
|
||||||
|
|
||||||
|
this.sandboxInfo = {
|
||||||
|
sandboxId,
|
||||||
|
url: `https://${host}`,
|
||||||
|
provider: 'e2b',
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set extended timeout on the sandbox instance if method available
|
||||||
|
if (typeof this.sandbox.setTimeout === 'function') {
|
||||||
|
this.sandbox.setTimeout(appConfig.e2b.timeoutMs);
|
||||||
|
console.log(`[E2BProvider] Set sandbox timeout to ${appConfig.e2b.timeoutMinutes} minutes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sandboxInfo;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[E2BProvider] Error creating sandbox:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runCommand(command: string): Promise<CommandResult> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[E2BProvider] Executing: ${command}`);
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCode(`
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir('/home/user/app')
|
||||||
|
result = subprocess.run(${JSON.stringify(command.split(' '))},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
shell=False)
|
||||||
|
|
||||||
|
print("STDOUT:")
|
||||||
|
print(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
print("\\nSTDERR:")
|
||||||
|
print(result.stderr)
|
||||||
|
print(f"\\nReturn code: {result.returncode}")
|
||||||
|
`);
|
||||||
|
|
||||||
|
const output = result.logs.stdout.join('\n');
|
||||||
|
const stderr = result.logs.stderr.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: output,
|
||||||
|
stderr,
|
||||||
|
exitCode: result.error ? 1 : 0,
|
||||||
|
success: !result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.startsWith('/') ? path : `/home/user/app/${path}`;
|
||||||
|
|
||||||
|
await this.sandbox.runCode(`
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
dir_path = os.path.dirname("${fullPath}")
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Write file
|
||||||
|
with open("${fullPath}", 'w') as f:
|
||||||
|
f.write(${JSON.stringify(content)})
|
||||||
|
print(f"✓ Written: ${fullPath}")
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.existingFiles.add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.startsWith('/') ? path : `/home/user/app/${path}`;
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCode(`
|
||||||
|
with open("${fullPath}", 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
print(content)
|
||||||
|
`);
|
||||||
|
|
||||||
|
return result.logs.stdout.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(directory: string = '/home/user/app'): Promise<string[]> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCode(`
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
def list_files(path):
|
||||||
|
files = []
|
||||||
|
for root, dirs, filenames in os.walk(path):
|
||||||
|
# Skip node_modules and .git
|
||||||
|
dirs[:] = [d for d in dirs if d not in ['node_modules', '.git', '.next', 'dist', 'build']]
|
||||||
|
for filename in filenames:
|
||||||
|
rel_path = os.path.relpath(os.path.join(root, filename), path)
|
||||||
|
files.append(rel_path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
files = list_files("${directory}")
|
||||||
|
print(json.dumps(files))
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(result.logs.stdout.join(''));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installPackages(packages: string[]): Promise<CommandResult> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageList = packages.join(' ');
|
||||||
|
const flags = appConfig.packages.useLegacyPeerDeps ? '--legacy-peer-deps' : '';
|
||||||
|
|
||||||
|
console.log(`[E2BProvider] Installing packages: ${packageList}`);
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCode(`
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir('/home/user/app')
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
result = subprocess.run(
|
||||||
|
['npm', 'install', ${flags ? `'${flags}',` : ''} ${packages.map(p => `'${p}'`).join(', ')}],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print("STDOUT:")
|
||||||
|
print(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
print("\\nSTDERR:")
|
||||||
|
print(result.stderr)
|
||||||
|
print(f"\\nReturn code: {result.returncode}")
|
||||||
|
`);
|
||||||
|
|
||||||
|
const output = result.logs.stdout.join('\n');
|
||||||
|
const stderr = result.logs.stderr.join('\n');
|
||||||
|
|
||||||
|
// Restart Vite if configured
|
||||||
|
if (appConfig.packages.autoRestartVite && !result.error) {
|
||||||
|
await this.restartViteServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: output,
|
||||||
|
stderr,
|
||||||
|
exitCode: result.error ? 1 : 0,
|
||||||
|
success: !result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupViteApp(): Promise<void> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[E2BProvider] Setting up Vite React app...');
|
||||||
|
|
||||||
|
// Write all files in a single Python script
|
||||||
|
const setupScript = `
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
print('Setting up React app with Vite and Tailwind...')
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
os.makedirs('/home/user/app/src', exist_ok=True)
|
||||||
|
|
||||||
|
# Package.json
|
||||||
|
package_json = {
|
||||||
|
"name": "sandbox-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"vite": "^4.3.9",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"autoprefixer": "^10.4.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with open('/home/user/app/package.json', 'w') as f:
|
||||||
|
json.dump(package_json, f, indent=2)
|
||||||
|
print('✓ package.json')
|
||||||
|
|
||||||
|
# Vite config
|
||||||
|
vite_config = """import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
hmr: false,
|
||||||
|
allowedHosts: ['.e2b.app', 'localhost', '127.0.0.1']
|
||||||
|
}
|
||||||
|
})"""
|
||||||
|
|
||||||
|
with open('/home/user/app/vite.config.js', 'w') as f:
|
||||||
|
f.write(vite_config)
|
||||||
|
print('✓ vite.config.js')
|
||||||
|
|
||||||
|
# Tailwind config
|
||||||
|
tailwind_config = """/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}"""
|
||||||
|
|
||||||
|
with open('/home/user/app/tailwind.config.js', 'w') as f:
|
||||||
|
f.write(tailwind_config)
|
||||||
|
print('✓ tailwind.config.js')
|
||||||
|
|
||||||
|
# PostCSS config
|
||||||
|
postcss_config = """export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}"""
|
||||||
|
|
||||||
|
with open('/home/user/app/postcss.config.js', 'w') as f:
|
||||||
|
f.write(postcss_config)
|
||||||
|
print('✓ postcss.config.js')
|
||||||
|
|
||||||
|
# Index.html
|
||||||
|
index_html = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sandbox App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
with open('/home/user/app/index.html', 'w') as f:
|
||||||
|
f.write(index_html)
|
||||||
|
print('✓ index.html')
|
||||||
|
|
||||||
|
# Main.jsx
|
||||||
|
main_jsx = """import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)"""
|
||||||
|
|
||||||
|
with open('/home/user/app/src/main.jsx', 'w') as f:
|
||||||
|
f.write(main_jsx)
|
||||||
|
print('✓ src/main.jsx')
|
||||||
|
|
||||||
|
# App.jsx
|
||||||
|
app_jsx = """function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center p-4">
|
||||||
|
<div className="text-center max-w-2xl">
|
||||||
|
<p className="text-lg text-gray-400">
|
||||||
|
Sandbox Ready<br/>
|
||||||
|
Start building your React app with Vite and Tailwind CSS!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App"""
|
||||||
|
|
||||||
|
with open('/home/user/app/src/App.jsx', 'w') as f:
|
||||||
|
f.write(app_jsx)
|
||||||
|
print('✓ src/App.jsx')
|
||||||
|
|
||||||
|
# Index.css
|
||||||
|
index_css = """@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}"""
|
||||||
|
|
||||||
|
with open('/home/user/app/src/index.css', 'w') as f:
|
||||||
|
f.write(index_css)
|
||||||
|
print('✓ src/index.css')
|
||||||
|
|
||||||
|
print('\\nAll files created successfully!')
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sandbox.runCode(setupScript);
|
||||||
|
|
||||||
|
// Install dependencies
|
||||||
|
console.log('[E2BProvider] Installing dependencies...');
|
||||||
|
await this.sandbox.runCode(`
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
print('Installing npm packages...')
|
||||||
|
result = subprocess.run(
|
||||||
|
['npm', 'install'],
|
||||||
|
cwd='/home/user/app',
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print('✓ Dependencies installed successfully')
|
||||||
|
else:
|
||||||
|
print(f'⚠ Warning: npm install had issues: {result.stderr}')
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Start Vite dev server
|
||||||
|
console.log('[E2BProvider] Starting Vite dev server...');
|
||||||
|
await this.sandbox.runCode(`
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
os.chdir('/home/user/app')
|
||||||
|
|
||||||
|
# Kill any existing Vite processes
|
||||||
|
subprocess.run(['pkill', '-f', 'vite'], capture_output=True)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Start Vite dev server
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['FORCE_COLOR'] = '0'
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
['npm', 'run', 'dev'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f'✓ Vite dev server started with PID: {process.pid}')
|
||||||
|
print('Waiting for server to be ready...')
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Wait for Vite to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, appConfig.e2b.viteStartupDelay));
|
||||||
|
|
||||||
|
// Track initial files
|
||||||
|
this.existingFiles.add('src/App.jsx');
|
||||||
|
this.existingFiles.add('src/main.jsx');
|
||||||
|
this.existingFiles.add('src/index.css');
|
||||||
|
this.existingFiles.add('index.html');
|
||||||
|
this.existingFiles.add('package.json');
|
||||||
|
this.existingFiles.add('vite.config.js');
|
||||||
|
this.existingFiles.add('tailwind.config.js');
|
||||||
|
this.existingFiles.add('postcss.config.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartViteServer(): Promise<void> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[E2BProvider] Restarting Vite server...');
|
||||||
|
|
||||||
|
await this.sandbox.runCode(`
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir('/home/user/app')
|
||||||
|
|
||||||
|
# Kill existing Vite process
|
||||||
|
subprocess.run(['pkill', '-f', 'vite'], capture_output=True)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Start Vite dev server
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['FORCE_COLOR'] = '0'
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
['npm', 'run', 'dev'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f'✓ Vite restarted with PID: {process.pid}')
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Wait for Vite to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, appConfig.e2b.viteStartupDelay));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSandboxUrl(): string | null {
|
||||||
|
return this.sandboxInfo?.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(): Promise<void> {
|
||||||
|
if (this.sandbox) {
|
||||||
|
console.log('[E2BProvider] Terminating sandbox...');
|
||||||
|
try {
|
||||||
|
await this.sandbox.kill();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to terminate sandbox:', e);
|
||||||
|
}
|
||||||
|
this.sandbox = null;
|
||||||
|
this.sandboxInfo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAlive(): boolean {
|
||||||
|
return !!this.sandbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import { Sandbox } from '@vercel/sandbox';
|
||||||
|
import { SandboxProvider, SandboxInfo, CommandResult, SandboxProviderConfig } from '../types';
|
||||||
|
|
||||||
|
export class VercelProvider extends SandboxProvider {
|
||||||
|
private existingFiles: Set<string> = new Set();
|
||||||
|
|
||||||
|
async createSandbox(): Promise<SandboxInfo> {
|
||||||
|
try {
|
||||||
|
console.log('[VercelProvider] Creating sandbox...');
|
||||||
|
|
||||||
|
// Kill existing sandbox if any
|
||||||
|
if (this.sandbox) {
|
||||||
|
console.log('[VercelProvider] Stopping existing sandbox...');
|
||||||
|
try {
|
||||||
|
await this.sandbox.stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to stop existing sandbox:', e);
|
||||||
|
}
|
||||||
|
this.sandbox = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing files tracking
|
||||||
|
this.existingFiles.clear();
|
||||||
|
|
||||||
|
// Create Vercel sandbox
|
||||||
|
console.log('[VercelProvider] Creating Vercel sandbox...');
|
||||||
|
|
||||||
|
const sandboxConfig: any = {
|
||||||
|
timeout: 300000, // 5 minutes in ms
|
||||||
|
runtime: 'node22', // Use node22 runtime for Vercel sandboxes
|
||||||
|
ports: [5173] // Vite port
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add authentication based on environment variables
|
||||||
|
if (process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID) {
|
||||||
|
console.log('[VercelProvider] Using personal access token authentication');
|
||||||
|
sandboxConfig.teamId = process.env.VERCEL_TEAM_ID;
|
||||||
|
sandboxConfig.projectId = process.env.VERCEL_PROJECT_ID;
|
||||||
|
sandboxConfig.token = process.env.VERCEL_TOKEN;
|
||||||
|
} else if (process.env.VERCEL_OIDC_TOKEN) {
|
||||||
|
console.log('[VercelProvider] Using OIDC token authentication');
|
||||||
|
} else {
|
||||||
|
console.log('[VercelProvider] No authentication found - relying on default Vercel authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sandbox = await Sandbox.create(sandboxConfig);
|
||||||
|
|
||||||
|
const sandboxId = this.sandbox.sandboxId;
|
||||||
|
console.log(`[VercelProvider] Sandbox created: ${sandboxId}`);
|
||||||
|
|
||||||
|
// Get the sandbox URL using the correct Vercel Sandbox API
|
||||||
|
const sandboxUrl = this.sandbox.domain(5173);
|
||||||
|
console.log(`[VercelProvider] Sandbox URL: ${sandboxUrl}`);
|
||||||
|
|
||||||
|
this.sandboxInfo = {
|
||||||
|
sandboxId,
|
||||||
|
url: sandboxUrl,
|
||||||
|
provider: 'vercel',
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sandboxInfo;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VercelProvider] Error creating sandbox:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runCommand(command: string): Promise<CommandResult> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[VercelProvider] Executing: ${command}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse command into cmd and args (matching PR syntax)
|
||||||
|
const parts = command.split(' ');
|
||||||
|
const cmd = parts[0];
|
||||||
|
const args = parts.slice(1);
|
||||||
|
|
||||||
|
// Vercel uses runCommand with cmd and args object (based on PR)
|
||||||
|
const result = await this.sandbox.runCommand({
|
||||||
|
cmd: cmd,
|
||||||
|
args: args,
|
||||||
|
cwd: '/app',
|
||||||
|
env: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: result.stdout || '',
|
||||||
|
stderr: result.stderr || '',
|
||||||
|
exitCode: result.exitCode || 0,
|
||||||
|
success: result.exitCode === 0
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
stdout: '',
|
||||||
|
stderr: error.message || 'Command failed',
|
||||||
|
exitCode: 1,
|
||||||
|
success: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.startsWith('/') ? path : `/app/${path}`;
|
||||||
|
|
||||||
|
// Based on PR, Vercel SDK has a writeFiles method that takes an array
|
||||||
|
try {
|
||||||
|
await this.sandbox.writeFiles([{
|
||||||
|
path: fullPath,
|
||||||
|
content: Buffer.from(content)
|
||||||
|
}]);
|
||||||
|
|
||||||
|
console.log(`[VercelProvider] Written: ${fullPath}`);
|
||||||
|
this.existingFiles.add(path);
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to command-based approach if writeFiles is not available
|
||||||
|
console.log(`[VercelProvider] writeFiles failed, using command fallback`);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'mkdir',
|
||||||
|
args: ['-p', dir],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write file using echo and redirection
|
||||||
|
const escapedContent = content
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, '\\$')
|
||||||
|
.replace(/`/g, '\\`')
|
||||||
|
.replace(/\n/g, '\\n');
|
||||||
|
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'sh',
|
||||||
|
args: ['-c', `echo "${escapedContent}" > ${fullPath}`],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[VercelProvider] Written via command: ${fullPath}`);
|
||||||
|
this.existingFiles.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.startsWith('/') ? path : `/app/${path}`;
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCommand({
|
||||||
|
cmd: 'cat',
|
||||||
|
args: [fullPath],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to read file: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(directory: string = '/app'): Promise<string[]> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCommand({
|
||||||
|
cmd: 'sh',
|
||||||
|
args: ['-c', `find ${directory} -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -not -path "*/build/*" | sed "s|^${directory}/||"`],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result.stdout || '').split('\n').filter((line: string) => line.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async installPackages(packages: string[]): Promise<CommandResult> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = process.env.NPM_FLAGS || '';
|
||||||
|
|
||||||
|
console.log(`[VercelProvider] Installing packages: ${packages.join(' ')}`);
|
||||||
|
|
||||||
|
// Build args array
|
||||||
|
const args = ['install'];
|
||||||
|
if (flags) {
|
||||||
|
args.push(...flags.split(' '));
|
||||||
|
}
|
||||||
|
args.push(...packages);
|
||||||
|
|
||||||
|
const result = await this.sandbox.runCommand({
|
||||||
|
cmd: 'npm',
|
||||||
|
args: args,
|
||||||
|
cwd: '/app'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart Vite if configured and successful
|
||||||
|
if (result.exitCode === 0 && process.env.AUTO_RESTART_VITE === 'true') {
|
||||||
|
await this.restartViteServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: result.stdout || '',
|
||||||
|
stderr: result.stderr || '',
|
||||||
|
exitCode: result.exitCode || 0,
|
||||||
|
success: result.exitCode === 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupViteApp(): Promise<void> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[VercelProvider] Setting up Vite React app...');
|
||||||
|
|
||||||
|
// Create directory structure
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'mkdir',
|
||||||
|
args: ['-p', '/app/src'],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create package.json
|
||||||
|
const packageJson = {
|
||||||
|
name: "sandbox-app",
|
||||||
|
version: "1.0.0",
|
||||||
|
type: "module",
|
||||||
|
scripts: {
|
||||||
|
dev: "vite --host",
|
||||||
|
build: "vite build",
|
||||||
|
preview: "vite preview"
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
react: "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
vite: "^4.3.9",
|
||||||
|
tailwindcss: "^3.3.0",
|
||||||
|
postcss: "^8.4.31",
|
||||||
|
autoprefixer: "^10.4.16"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.writeFile('package.json', JSON.stringify(packageJson, null, 2));
|
||||||
|
|
||||||
|
// Create vite.config.js
|
||||||
|
const viteConfig = `import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
hmr: {
|
||||||
|
clientPort: 443,
|
||||||
|
protocol: 'wss'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})`;
|
||||||
|
|
||||||
|
await this.writeFile('vite.config.js', viteConfig);
|
||||||
|
|
||||||
|
// Create tailwind.config.js
|
||||||
|
const tailwindConfig = `/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}`;
|
||||||
|
|
||||||
|
await this.writeFile('tailwind.config.js', tailwindConfig);
|
||||||
|
|
||||||
|
// Create postcss.config.js
|
||||||
|
const postcssConfig = `export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}`;
|
||||||
|
|
||||||
|
await this.writeFile('postcss.config.js', postcssConfig);
|
||||||
|
|
||||||
|
// Create index.html
|
||||||
|
const indexHtml = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sandbox App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
await this.writeFile('index.html', indexHtml);
|
||||||
|
|
||||||
|
// Create src/main.jsx
|
||||||
|
const mainJsx = `import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)`;
|
||||||
|
|
||||||
|
await this.writeFile('src/main.jsx', mainJsx);
|
||||||
|
|
||||||
|
// Create src/App.jsx
|
||||||
|
const appJsx = `function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center p-4">
|
||||||
|
<div className="text-center max-w-2xl">
|
||||||
|
<p className="text-lg text-gray-400">
|
||||||
|
Vercel Sandbox Ready<br/>
|
||||||
|
Start building your React app with Vite and Tailwind CSS!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App`;
|
||||||
|
|
||||||
|
await this.writeFile('src/App.jsx', appJsx);
|
||||||
|
|
||||||
|
// Create src/index.css
|
||||||
|
const indexCss = `@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
await this.writeFile('src/index.css', indexCss);
|
||||||
|
|
||||||
|
console.log('[VercelProvider] All files created successfully!');
|
||||||
|
|
||||||
|
// Install dependencies
|
||||||
|
console.log('[VercelProvider] Installing dependencies...');
|
||||||
|
const installResult = await this.sandbox.runCommand({
|
||||||
|
cmd: 'npm',
|
||||||
|
args: ['install'],
|
||||||
|
cwd: '/app'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (installResult.exitCode === 0) {
|
||||||
|
console.log('[VercelProvider] Dependencies installed successfully');
|
||||||
|
} else {
|
||||||
|
console.warn('[VercelProvider] npm install had issues:', installResult.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Vite dev server
|
||||||
|
console.log('[VercelProvider] Starting Vite dev server...');
|
||||||
|
|
||||||
|
// Kill any existing Vite processes
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'sh',
|
||||||
|
args: ['-c', 'pkill -f vite || true'],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start Vite in background
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'sh',
|
||||||
|
args: ['-c', 'nohup npm run dev > /tmp/vite.log 2>&1 &'],
|
||||||
|
cwd: '/app'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[VercelProvider] Vite dev server started');
|
||||||
|
|
||||||
|
// Wait for Vite to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 7000));
|
||||||
|
|
||||||
|
// Track initial files
|
||||||
|
this.existingFiles.add('src/App.jsx');
|
||||||
|
this.existingFiles.add('src/main.jsx');
|
||||||
|
this.existingFiles.add('src/index.css');
|
||||||
|
this.existingFiles.add('index.html');
|
||||||
|
this.existingFiles.add('package.json');
|
||||||
|
this.existingFiles.add('vite.config.js');
|
||||||
|
this.existingFiles.add('tailwind.config.js');
|
||||||
|
this.existingFiles.add('postcss.config.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartViteServer(): Promise<void> {
|
||||||
|
if (!this.sandbox) {
|
||||||
|
throw new Error('No active sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[VercelProvider] Restarting Vite server...');
|
||||||
|
|
||||||
|
// Kill existing Vite process
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'sh',
|
||||||
|
args: ['-c', 'pkill -f vite || true'],
|
||||||
|
cwd: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a moment
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// Start Vite in background
|
||||||
|
await this.sandbox.runCommand({
|
||||||
|
cmd: 'sh',
|
||||||
|
args: ['-c', 'nohup npm run dev > /tmp/vite.log 2>&1 &'],
|
||||||
|
cwd: '/app'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[VercelProvider] Vite restarted');
|
||||||
|
|
||||||
|
// Wait for Vite to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 7000));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSandboxUrl(): string | null {
|
||||||
|
return this.sandboxInfo?.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(): Promise<void> {
|
||||||
|
if (this.sandbox) {
|
||||||
|
console.log('[VercelProvider] Terminating sandbox...');
|
||||||
|
try {
|
||||||
|
await this.sandbox.stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to terminate sandbox:', e);
|
||||||
|
}
|
||||||
|
this.sandbox = null;
|
||||||
|
this.sandboxInfo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAlive(): boolean {
|
||||||
|
return !!this.sandbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface SandboxFile {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
lastModified?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SandboxInfo {
|
||||||
|
sandboxId: string;
|
||||||
|
url: string;
|
||||||
|
provider: 'e2b' | 'vercel';
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SandboxProviderConfig {
|
||||||
|
e2b?: {
|
||||||
|
apiKey: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
template?: string;
|
||||||
|
};
|
||||||
|
vercel?: {
|
||||||
|
teamId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
token?: string;
|
||||||
|
authMethod?: 'oidc' | 'pat';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class SandboxProvider {
|
||||||
|
protected config: SandboxProviderConfig;
|
||||||
|
protected sandbox: any;
|
||||||
|
protected sandboxInfo: SandboxInfo | null = null;
|
||||||
|
|
||||||
|
constructor(config: SandboxProviderConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract createSandbox(): Promise<SandboxInfo>;
|
||||||
|
abstract runCommand(command: string): Promise<CommandResult>;
|
||||||
|
abstract writeFile(path: string, content: string): Promise<void>;
|
||||||
|
abstract readFile(path: string): Promise<string>;
|
||||||
|
abstract listFiles(directory?: string): Promise<string[]>;
|
||||||
|
abstract installPackages(packages: string[]): Promise<CommandResult>;
|
||||||
|
abstract getSandboxUrl(): string | null;
|
||||||
|
abstract terminate(): Promise<void>;
|
||||||
|
abstract isAlive(): boolean;
|
||||||
|
|
||||||
|
// Optional methods that providers can override
|
||||||
|
async setupViteApp(): Promise<void> {
|
||||||
|
// Default implementation for setting up a Vite React app
|
||||||
|
throw new Error('setupViteApp not implemented for this provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartViteServer(): Promise<void> {
|
||||||
|
// Default implementation for restarting Vite
|
||||||
|
throw new Error('restartViteServer not implemented for this provider');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import ora from 'ora';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { installer } from './lib/installer.js';
|
||||||
|
import { getPrompts } from './lib/prompts.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('create-open-lovable')
|
||||||
|
.description('Create a new Open Lovable project with your choice of sandbox provider')
|
||||||
|
.version('1.0.0')
|
||||||
|
.option('-s, --sandbox <provider>', 'Sandbox provider (e2b or vercel)')
|
||||||
|
.option('-n, --name <name>', 'Project name')
|
||||||
|
.option('-p, --path <path>', 'Installation path (defaults to current directory)')
|
||||||
|
.option('--skip-install', 'Skip npm install')
|
||||||
|
.option('--dry-run', 'Run without making changes')
|
||||||
|
.parse(process.argv);
|
||||||
|
|
||||||
|
const options = program.opts();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(chalk.cyan('\n🚀 Welcome to Open Lovable Setup!\n'));
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
sandbox: options.sandbox,
|
||||||
|
name: options.name || 'my-open-lovable',
|
||||||
|
path: options.path || process.cwd(),
|
||||||
|
skipInstall: options.skipInstall || false,
|
||||||
|
dryRun: options.dryRun || false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interactive mode if sandbox not specified
|
||||||
|
if (!config.sandbox) {
|
||||||
|
const prompts = getPrompts(config);
|
||||||
|
const answers = await inquirer.prompt(prompts);
|
||||||
|
config = { ...config, ...answers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sandbox provider
|
||||||
|
if (!['e2b', 'vercel'].includes(config.sandbox)) {
|
||||||
|
console.error(chalk.red(`\n❌ Invalid sandbox provider: ${config.sandbox}`));
|
||||||
|
console.log(chalk.yellow('Valid options: e2b, vercel\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.gray('\nConfiguration:'));
|
||||||
|
console.log(chalk.gray(` Project: ${config.name}`));
|
||||||
|
console.log(chalk.gray(` Sandbox: ${config.sandbox}`));
|
||||||
|
console.log(chalk.gray(` Path: ${path.resolve(config.path, config.name)}\n`));
|
||||||
|
|
||||||
|
if (config.dryRun) {
|
||||||
|
console.log(chalk.yellow('🔍 Dry run mode - no files will be created\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const spinner = ora('Creating project...').start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installer({
|
||||||
|
...config,
|
||||||
|
templatesDir: path.join(__dirname, 'templates')
|
||||||
|
});
|
||||||
|
|
||||||
|
spinner.succeed('Project created successfully!');
|
||||||
|
|
||||||
|
console.log(chalk.green('\n✅ Setup complete!\n'));
|
||||||
|
console.log(chalk.white('Next steps:'));
|
||||||
|
console.log(chalk.gray(` 1. cd ${config.name}`));
|
||||||
|
console.log(chalk.gray(` 2. Copy .env.example to .env and add your API keys`));
|
||||||
|
console.log(chalk.gray(` 3. npm run dev`));
|
||||||
|
console.log(chalk.gray('\nHappy coding! 🎉\n'));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
spinner.fail('Setup failed');
|
||||||
|
console.error(chalk.red('\n❌ Error:'), error.message);
|
||||||
|
if (error.stack && process.env.DEBUG) {
|
||||||
|
console.error(chalk.gray(error.stack));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(chalk.red('Unexpected error:'), error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import { getEnvPrompts } from './prompts.js';
|
||||||
|
|
||||||
|
export async function installer(config) {
|
||||||
|
const { name, sandbox, path: installPath, skipInstall, dryRun, templatesDir } = config;
|
||||||
|
const projectPath = path.join(installPath, name);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(chalk.blue('\n📋 Dry run - would perform these actions:'));
|
||||||
|
console.log(chalk.gray(` - Create directory: ${projectPath}`));
|
||||||
|
console.log(chalk.gray(` - Copy base template files`));
|
||||||
|
console.log(chalk.gray(` - Copy ${sandbox}-specific files`));
|
||||||
|
console.log(chalk.gray(` - Create .env file`));
|
||||||
|
if (!skipInstall) {
|
||||||
|
console.log(chalk.gray(` - Run npm install`));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
if (await fs.pathExists(projectPath)) {
|
||||||
|
const { overwrite } = await inquirer.prompt([{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'overwrite',
|
||||||
|
message: `Directory ${name} already exists. Overwrite?`,
|
||||||
|
default: false
|
||||||
|
}]);
|
||||||
|
|
||||||
|
if (!overwrite) {
|
||||||
|
throw new Error('Installation cancelled');
|
||||||
|
}
|
||||||
|
await fs.remove(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project directory
|
||||||
|
await fs.ensureDir(projectPath);
|
||||||
|
|
||||||
|
// Copy base template (shared files)
|
||||||
|
const baseTemplatePath = path.join(templatesDir, 'base');
|
||||||
|
if (await fs.pathExists(baseTemplatePath)) {
|
||||||
|
await copyTemplate(baseTemplatePath, projectPath);
|
||||||
|
} else {
|
||||||
|
// If no base template exists yet, copy from the main project
|
||||||
|
await copyMainProject(path.dirname(templatesDir), projectPath, sandbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy provider-specific template
|
||||||
|
const providerTemplatePath = path.join(templatesDir, sandbox);
|
||||||
|
if (await fs.pathExists(providerTemplatePath)) {
|
||||||
|
await copyTemplate(providerTemplatePath, projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure environment variables
|
||||||
|
if (config.configureEnv) {
|
||||||
|
const envAnswers = await inquirer.prompt(getEnvPrompts(sandbox));
|
||||||
|
await createEnvFile(projectPath, sandbox, envAnswers);
|
||||||
|
} else {
|
||||||
|
// Create .env.example copy
|
||||||
|
await createEnvExample(projectPath, sandbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update package.json with project name
|
||||||
|
await updatePackageJson(projectPath, name);
|
||||||
|
|
||||||
|
// Update configuration to use the selected sandbox provider
|
||||||
|
await updateAppConfig(projectPath, sandbox);
|
||||||
|
|
||||||
|
// Install dependencies
|
||||||
|
if (!skipInstall) {
|
||||||
|
console.log(chalk.cyan('\n📦 Installing dependencies...'));
|
||||||
|
execSync('npm install', {
|
||||||
|
cwd: projectPath,
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTemplate(src, dest) {
|
||||||
|
const files = await fs.readdir(src);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const srcPath = path.join(src, file);
|
||||||
|
const destPath = path.join(dest, file);
|
||||||
|
|
||||||
|
const stat = await fs.stat(srcPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await fs.ensureDir(destPath);
|
||||||
|
await copyTemplate(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
await fs.copy(srcPath, destPath, { overwrite: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMainProject(mainProjectPath, projectPath, sandbox) {
|
||||||
|
// Copy essential directories and files from the main project
|
||||||
|
const itemsToCopy = [
|
||||||
|
'app',
|
||||||
|
'components',
|
||||||
|
'config',
|
||||||
|
'lib',
|
||||||
|
'types',
|
||||||
|
'public',
|
||||||
|
'styles',
|
||||||
|
'.eslintrc.json',
|
||||||
|
'.gitignore',
|
||||||
|
'next.config.js',
|
||||||
|
'package.json',
|
||||||
|
'tailwind.config.ts',
|
||||||
|
'tsconfig.json',
|
||||||
|
'postcss.config.mjs'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of itemsToCopy) {
|
||||||
|
const srcPath = path.join(mainProjectPath, '..', item);
|
||||||
|
const destPath = path.join(projectPath, item);
|
||||||
|
|
||||||
|
if (await fs.pathExists(srcPath)) {
|
||||||
|
await fs.copy(srcPath, destPath, {
|
||||||
|
overwrite: true,
|
||||||
|
filter: (src) => {
|
||||||
|
// Skip node_modules and .next
|
||||||
|
if (src.includes('node_modules') || src.includes('.next')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEnvFile(projectPath, sandbox, answers) {
|
||||||
|
let envContent = '# Open Lovable Configuration\n\n';
|
||||||
|
|
||||||
|
// Sandbox provider
|
||||||
|
envContent += `# Sandbox Provider\n`;
|
||||||
|
envContent += `SANDBOX_PROVIDER=${sandbox}\n\n`;
|
||||||
|
|
||||||
|
// Required keys
|
||||||
|
envContent += `# REQUIRED - Web scraping for cloning websites\n`;
|
||||||
|
envContent += `FIRECRAWL_API_KEY=${answers.firecrawlApiKey || 'your_firecrawl_api_key_here'}\n\n`;
|
||||||
|
|
||||||
|
if (sandbox === 'e2b') {
|
||||||
|
envContent += `# REQUIRED - E2B Sandboxes\n`;
|
||||||
|
envContent += `E2B_API_KEY=${answers.e2bApiKey || 'your_e2b_api_key_here'}\n\n`;
|
||||||
|
} else if (sandbox === 'vercel') {
|
||||||
|
envContent += `# REQUIRED - Vercel Sandboxes\n`;
|
||||||
|
if (answers.vercelAuthMethod === 'oidc') {
|
||||||
|
envContent += `# Using OIDC authentication (automatic in Vercel environment)\n`;
|
||||||
|
} else {
|
||||||
|
envContent += `VERCEL_TEAM_ID=${answers.vercelTeamId || 'your_team_id'}\n`;
|
||||||
|
envContent += `VERCEL_PROJECT_ID=${answers.vercelProjectId || 'your_project_id'}\n`;
|
||||||
|
envContent += `VERCEL_TOKEN=${answers.vercelToken || 'your_access_token'}\n`;
|
||||||
|
}
|
||||||
|
envContent += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional AI provider keys
|
||||||
|
envContent += `# OPTIONAL - AI Providers\n`;
|
||||||
|
|
||||||
|
if (answers.anthropicApiKey) {
|
||||||
|
envContent += `ANTHROPIC_API_KEY=${answers.anthropicApiKey}\n`;
|
||||||
|
} else {
|
||||||
|
envContent += `# ANTHROPIC_API_KEY=your_anthropic_api_key_here\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answers.openaiApiKey) {
|
||||||
|
envContent += `OPENAI_API_KEY=${answers.openaiApiKey}\n`;
|
||||||
|
} else {
|
||||||
|
envContent += `# OPENAI_API_KEY=your_openai_api_key_here\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answers.geminiApiKey) {
|
||||||
|
envContent += `GEMINI_API_KEY=${answers.geminiApiKey}\n`;
|
||||||
|
} else {
|
||||||
|
envContent += `# GEMINI_API_KEY=your_gemini_api_key_here\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answers.groqApiKey) {
|
||||||
|
envContent += `GROQ_API_KEY=${answers.groqApiKey}\n`;
|
||||||
|
} else {
|
||||||
|
envContent += `# GROQ_API_KEY=your_groq_api_key_here\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(projectPath, '.env'), envContent);
|
||||||
|
await fs.writeFile(path.join(projectPath, '.env.example'), envContent.replace(/=.+/g, '=your_key_here'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEnvExample(projectPath, sandbox) {
|
||||||
|
let envContent = '# Open Lovable Configuration\n\n';
|
||||||
|
|
||||||
|
envContent += `# Sandbox Provider\n`;
|
||||||
|
envContent += `SANDBOX_PROVIDER=${sandbox}\n\n`;
|
||||||
|
|
||||||
|
envContent += `# REQUIRED - Web scraping for cloning websites\n`;
|
||||||
|
envContent += `# Get yours at https://firecrawl.dev\n`;
|
||||||
|
envContent += `FIRECRAWL_API_KEY=your_firecrawl_api_key_here\n\n`;
|
||||||
|
|
||||||
|
if (sandbox === 'e2b') {
|
||||||
|
envContent += `# REQUIRED - Sandboxes for code execution\n`;
|
||||||
|
envContent += `# Get yours at https://e2b.dev\n`;
|
||||||
|
envContent += `E2B_API_KEY=your_e2b_api_key_here\n\n`;
|
||||||
|
} else if (sandbox === 'vercel') {
|
||||||
|
envContent += `# REQUIRED - Vercel Sandboxes\n`;
|
||||||
|
envContent += `# Option 1: OIDC (automatic in Vercel environment)\n`;
|
||||||
|
envContent += `# Option 2: Personal Access Token\n`;
|
||||||
|
envContent += `VERCEL_TEAM_ID=your_team_id\n`;
|
||||||
|
envContent += `VERCEL_PROJECT_ID=your_project_id\n`;
|
||||||
|
envContent += `VERCEL_TOKEN=your_access_token\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
envContent += `# OPTIONAL - AI Providers (need at least one)\n`;
|
||||||
|
envContent += `# Get yours at https://console.anthropic.com\n`;
|
||||||
|
envContent += `ANTHROPIC_API_KEY=your_anthropic_api_key_here\n\n`;
|
||||||
|
envContent += `# Get yours at https://platform.openai.com\n`;
|
||||||
|
envContent += `OPENAI_API_KEY=your_openai_api_key_here\n\n`;
|
||||||
|
envContent += `# Get yours at https://aistudio.google.com/app/apikey\n`;
|
||||||
|
envContent += `GEMINI_API_KEY=your_gemini_api_key_here\n\n`;
|
||||||
|
envContent += `# Get yours at https://console.groq.com\n`;
|
||||||
|
envContent += `GROQ_API_KEY=your_groq_api_key_here\n`;
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(projectPath, '.env.example'), envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePackageJson(projectPath, name) {
|
||||||
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||||
|
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
const packageJson = await fs.readJson(packageJsonPath);
|
||||||
|
packageJson.name = name;
|
||||||
|
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAppConfig(projectPath, sandbox) {
|
||||||
|
const configPath = path.join(projectPath, 'config', 'app.config.ts');
|
||||||
|
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
let content = await fs.readFile(configPath, 'utf-8');
|
||||||
|
|
||||||
|
// Add sandbox provider configuration
|
||||||
|
const sandboxConfig = `
|
||||||
|
// Sandbox Provider Configuration
|
||||||
|
sandboxProvider: process.env.SANDBOX_PROVIDER || '${sandbox}',
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert after the opening of appConfig
|
||||||
|
content = content.replace(
|
||||||
|
'export const appConfig = {',
|
||||||
|
`export const appConfig = {${sandboxConfig}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
export function getPrompts(config) {
|
||||||
|
const prompts = [];
|
||||||
|
|
||||||
|
if (!config.name) {
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'name',
|
||||||
|
message: 'Project name:',
|
||||||
|
default: 'my-open-lovable',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return 'Project name is required';
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9-_]+$/i.test(input)) {
|
||||||
|
return 'Project name can only contain letters, numbers, hyphens, and underscores';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.sandbox) {
|
||||||
|
prompts.push({
|
||||||
|
type: 'list',
|
||||||
|
name: 'sandbox',
|
||||||
|
message: 'Choose your sandbox provider:',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
name: 'E2B - Full-featured development sandboxes',
|
||||||
|
value: 'e2b',
|
||||||
|
short: 'E2B'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vercel - Lightweight ephemeral VMs',
|
||||||
|
value: 'vercel',
|
||||||
|
short: 'Vercel'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
default: 'e2b'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'configureEnv',
|
||||||
|
message: 'Would you like to configure API keys now?',
|
||||||
|
default: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return prompts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvPrompts(provider) {
|
||||||
|
const prompts = [];
|
||||||
|
|
||||||
|
// Always include Firecrawl API key
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'firecrawlApiKey',
|
||||||
|
message: 'Firecrawl API key (for web scraping):',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return 'Firecrawl API key is required for web scraping functionality';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (provider === 'e2b') {
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'e2bApiKey',
|
||||||
|
message: 'E2B API key:',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return 'E2B API key is required';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (provider === 'vercel') {
|
||||||
|
prompts.push({
|
||||||
|
type: 'list',
|
||||||
|
name: 'vercelAuthMethod',
|
||||||
|
message: 'Vercel authentication method:',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
name: 'OIDC Token (automatic in Vercel environment)',
|
||||||
|
value: 'oidc',
|
||||||
|
short: 'OIDC'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Personal Access Token',
|
||||||
|
value: 'pat',
|
||||||
|
short: 'PAT'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'vercelTeamId',
|
||||||
|
message: 'Vercel Team ID:',
|
||||||
|
when: (answers) => answers.vercelAuthMethod === 'pat',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return 'Team ID is required for PAT authentication';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'vercelProjectId',
|
||||||
|
message: 'Vercel Project ID:',
|
||||||
|
when: (answers) => answers.vercelAuthMethod === 'pat',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return 'Project ID is required for PAT authentication';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'vercelToken',
|
||||||
|
message: 'Vercel Access Token:',
|
||||||
|
when: (answers) => answers.vercelAuthMethod === 'pat',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return 'Access token is required for PAT authentication';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional AI provider keys
|
||||||
|
prompts.push({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'addAiKeys',
|
||||||
|
message: 'Would you like to add AI provider API keys?',
|
||||||
|
default: true
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'checkbox',
|
||||||
|
name: 'aiProviders',
|
||||||
|
message: 'Select AI providers to configure:',
|
||||||
|
when: (answers) => answers.addAiKeys,
|
||||||
|
choices: [
|
||||||
|
{ name: 'Anthropic (Claude)', value: 'anthropic' },
|
||||||
|
{ name: 'OpenAI (GPT)', value: 'openai' },
|
||||||
|
{ name: 'Google (Gemini)', value: 'gemini' },
|
||||||
|
{ name: 'Groq', value: 'groq' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'anthropicApiKey',
|
||||||
|
message: 'Anthropic API key:',
|
||||||
|
when: (answers) => answers.aiProviders && answers.aiProviders.includes('anthropic')
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'openaiApiKey',
|
||||||
|
message: 'OpenAI API key:',
|
||||||
|
when: (answers) => answers.aiProviders && answers.aiProviders.includes('openai')
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'geminiApiKey',
|
||||||
|
message: 'Gemini API key:',
|
||||||
|
when: (answers) => answers.aiProviders && answers.aiProviders.includes('gemini')
|
||||||
|
});
|
||||||
|
|
||||||
|
prompts.push({
|
||||||
|
type: 'input',
|
||||||
|
name: 'groqApiKey',
|
||||||
|
message: 'Groq API key:',
|
||||||
|
when: (answers) => answers.aiProviders && answers.aiProviders.includes('groq')
|
||||||
|
});
|
||||||
|
|
||||||
|
return prompts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "create-open-lovable",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CLI to bootstrap Open Lovable with your choice of sandbox provider",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"bin": {
|
||||||
|
"create-open-lovable": "./index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node index.js --dry-run"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"lovable",
|
||||||
|
"sandbox",
|
||||||
|
"e2b",
|
||||||
|
"vercel",
|
||||||
|
"ai",
|
||||||
|
"code-generation"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"commander": "^11.1.0",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
|
"inquirer": "^9.2.12",
|
||||||
|
"ora": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Open Lovable Configuration - E2B Provider
|
||||||
|
|
||||||
|
# Sandbox Provider
|
||||||
|
SANDBOX_PROVIDER=e2b
|
||||||
|
|
||||||
|
# REQUIRED - Sandboxes for code execution
|
||||||
|
# Get yours at https://e2b.dev
|
||||||
|
E2B_API_KEY=your_e2b_api_key_here
|
||||||
|
|
||||||
|
# REQUIRED - Web scraping for cloning websites
|
||||||
|
# Get yours at https://firecrawl.dev
|
||||||
|
FIRECRAWL_API_KEY=your_firecrawl_api_key_here
|
||||||
|
|
||||||
|
# OPTIONAL - AI Providers (need at least one)
|
||||||
|
# Get yours at https://console.anthropic.com
|
||||||
|
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||||
|
|
||||||
|
# Get yours at https://platform.openai.com
|
||||||
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
# Get yours at https://aistudio.google.com/app/apikey
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# Get yours at https://console.groq.com
|
||||||
|
GROQ_API_KEY=your_groq_api_key_here
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Open Lovable - E2B Sandbox
|
||||||
|
|
||||||
|
This project is configured to use E2B sandboxes for code execution.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Get your E2B API key from [https://e2b.dev](https://e2b.dev)
|
||||||
|
2. Get your Firecrawl API key from [https://firecrawl.dev](https://firecrawl.dev)
|
||||||
|
3. Copy `.env.example` to `.env` and add your API keys
|
||||||
|
4. Run `npm install` to install dependencies
|
||||||
|
5. Run `npm run dev` to start the development server
|
||||||
|
|
||||||
|
## E2B Features
|
||||||
|
|
||||||
|
- Full-featured development sandboxes
|
||||||
|
- 15-minute default timeout (configurable)
|
||||||
|
- Persistent file system during session
|
||||||
|
- Support for complex package installations
|
||||||
|
- Built-in Python runtime for code execution
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can adjust E2B settings in `config/app.config.ts`:
|
||||||
|
|
||||||
|
- `timeoutMinutes`: Sandbox session timeout (default: 15)
|
||||||
|
- `vitePort`: Development server port (default: 5173)
|
||||||
|
- `viteStartupDelay`: Time to wait for Vite to start (default: 7000ms)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. Verify your E2B API key is valid
|
||||||
|
2. Check the console for detailed error messages
|
||||||
|
3. Ensure you have a stable internet connection
|
||||||
|
4. Try refreshing the page and creating a new sandbox
|
||||||
|
|
||||||
|
For more help, visit the [E2B documentation](https://docs.e2b.dev).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Open Lovable Configuration - Vercel Provider
|
||||||
|
|
||||||
|
# Sandbox Provider
|
||||||
|
SANDBOX_PROVIDER=vercel
|
||||||
|
|
||||||
|
# REQUIRED - Vercel Sandboxes
|
||||||
|
# Option 1: OIDC Token (automatic in Vercel environment)
|
||||||
|
# Option 2: Personal Access Token (configure below)
|
||||||
|
VERCEL_TEAM_ID=your_team_id
|
||||||
|
VERCEL_PROJECT_ID=your_project_id
|
||||||
|
VERCEL_TOKEN=your_access_token
|
||||||
|
|
||||||
|
# REQUIRED - Web scraping for cloning websites
|
||||||
|
# Get yours at https://firecrawl.dev
|
||||||
|
FIRECRAWL_API_KEY=your_firecrawl_api_key_here
|
||||||
|
|
||||||
|
# OPTIONAL - AI Providers (need at least one)
|
||||||
|
# Get yours at https://console.anthropic.com
|
||||||
|
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||||
|
|
||||||
|
# Get yours at https://platform.openai.com
|
||||||
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
# Get yours at https://aistudio.google.com/app/apikey
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# Get yours at https://console.groq.com
|
||||||
|
GROQ_API_KEY=your_groq_api_key_here
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Open Lovable - Vercel Sandbox
|
||||||
|
|
||||||
|
This project is configured to use Vercel Sandboxes for code execution.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Configure Vercel authentication (see below)
|
||||||
|
2. Get your Firecrawl API key from [https://firecrawl.dev](https://firecrawl.dev)
|
||||||
|
3. Copy `.env.example` to `.env` and add your credentials
|
||||||
|
4. Run `npm install` to install dependencies
|
||||||
|
5. Run `npm run dev` to start the development server
|
||||||
|
|
||||||
|
## Vercel Authentication
|
||||||
|
|
||||||
|
### Option 1: OIDC Token (Recommended for Vercel deployments)
|
||||||
|
When running in a Vercel environment, authentication happens automatically via OIDC tokens. No configuration needed!
|
||||||
|
|
||||||
|
### Option 2: Personal Access Token (For local development)
|
||||||
|
1. Create a Personal Access Token in your [Vercel account settings](https://vercel.com/account/tokens)
|
||||||
|
2. Get your Team ID from your [team settings](https://vercel.com/teams)
|
||||||
|
3. Create a project and get the Project ID
|
||||||
|
4. Add these to your `.env` file:
|
||||||
|
- `VERCEL_TOKEN`
|
||||||
|
- `VERCEL_TEAM_ID`
|
||||||
|
- `VERCEL_PROJECT_ID`
|
||||||
|
|
||||||
|
## Vercel Sandbox Features
|
||||||
|
|
||||||
|
- Lightweight ephemeral Linux VMs
|
||||||
|
- Powered by Firecracker MicroVMs
|
||||||
|
- 5-minute default timeout (max 45 minutes)
|
||||||
|
- 8 vCPUs maximum
|
||||||
|
- Root access for package installation
|
||||||
|
- Node 22 runtime included
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can adjust Vercel settings in `config/app.config.ts`:
|
||||||
|
|
||||||
|
- `maxDuration`: Sandbox session timeout (default: 5 minutes)
|
||||||
|
- Authentication method (OIDC or PAT)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. Verify your authentication credentials
|
||||||
|
2. Check if you're using the correct authentication method
|
||||||
|
3. Ensure your Vercel account has sandbox access
|
||||||
|
4. Check the console for detailed error messages
|
||||||
|
|
||||||
|
For more help, visit the [Vercel Sandbox documentation](https://vercel.com/docs/vercel-sandbox).
|
||||||
Reference in New Issue
Block a user