diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca96040 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-slim + +# Install system dependencies +RUN apt-get update \ + && apt-get install -y \ + bash \ + build-essential \ + ca-certificates \ + curl \ + g++ \ + git \ + make \ + python3 \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# Install bun +RUN /bin/bash -o pipefail -c 'curl -fsSL https://bun.sh/install | bash' +ENV PATH=/root/.bun/bin:$PATH + +# Create necessary directories +RUN mkdir -p /aidev /workspace +WORKDIR /aidev + +# Install dependencies +COPY package.json bun.lockb ./ +RUN bun install --verbose \ + && cd /aidev/node_modules/tree-sitter-typescript \ + && npm rebuild --build-from-source \ + && cd /aidev/node_modules/tree-sitter \ + && npm rebuild --build-from-source \ + && npm rebuild tree-sitter-go --build-from-source + +ENTRYPOINT ["bun", "--cwd", "/aidev", "dev"] diff --git a/debug-docker.js b/debug-docker.js new file mode 100644 index 0000000..47ef7f8 --- /dev/null +++ b/debug-docker.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process'); +const { dirname } = require('path'); + +// Simple debug script to test Docker setup +function debugDocker() { + const hostAidevDir = dirname(__dirname); + const hostWorkspace = process.cwd(); + + console.log('=== Docker Debug Information ==='); + console.log('Host aidev directory:', hostAidevDir); + console.log('Host workspace:', hostWorkspace); + console.log(); + + // Test 1: Check if Docker is running + console.log('1. Testing Docker availability...'); + const dockerTest = spawn('docker', ['--version'], { stdio: 'pipe' }); + + dockerTest.stdout.on('data', (data) => { + console.log('Docker version:', data.toString().trim()); + }); + + dockerTest.stderr.on('data', (data) => { + console.error('Docker version error:', data.toString().trim()); + }); + + dockerTest.on('close', (code) => { + if (code === 0) { + console.log('✓ Docker is available\n'); + testDockerImage(); + } else { + console.log('✗ Docker is not available or not running\n'); + process.exit(1); + } + }); +} + +function testDockerImage() { + console.log('2. Testing Docker image availability...'); + const imageTest = spawn('docker', ['images', 'aidev:latest'], { stdio: 'pipe' }); + + let output = ''; + imageTest.stdout.on('data', (data) => { + output += data.toString(); + }); + + imageTest.on('close', (code) => { + if (output.includes('aidev') && output.includes('latest')) { + console.log('✓ aidev:latest image found\n'); + testSimpleContainer(); + } else { + console.log('✗ aidev:latest image not found'); + console.log('Available images:'); + console.log(output); + console.log('\nYou may need to build the Docker image first.\n'); + process.exit(1); + } + }); +} + +function testSimpleContainer() { + console.log('3. Testing simple container execution...'); + const containerTest = spawn('docker', [ + 'run', '--rm', 'aidev:latest', 'echo', 'Container test successful' + ], { stdio: 'pipe' }); + + containerTest.stdout.on('data', (data) => { + console.log('Container output:', data.toString().trim()); + }); + + containerTest.stderr.on('data', (data) => { + console.error('Container error:', data.toString().trim()); + }); + + containerTest.on('close', (code) => { + if (code === 0) { + console.log('✓ Simple container execution successful\n'); + testMountedContainer(); + } else { + console.log('✗ Simple container execution failed\n'); + process.exit(1); + } + }); +} + +function testMountedContainer() { + console.log('4. Testing container with mounts...'); + const hostAidevDir = dirname(__dirname); + const hostWorkspace = process.cwd(); + + const mountTest = spawn('docker', [ + 'run', '--rm', + '-v', `${hostWorkspace}:/workspace:rw`, + '-v', `${hostAidevDir}:/aidev:ro`, + 'aidev:latest', + 'ls', '-la', '/workspace', '/aidev' + ], { stdio: 'pipe' }); + + mountTest.stdout.on('data', (data) => { + console.log('Mount test output:', data.toString()); + }); + + mountTest.stderr.on('data', (data) => { + console.error('Mount test error:', data.toString()); + }); + + mountTest.on('close', (code) => { + if (code === 0) { + console.log('✓ Container mounts working\n'); + testInteractiveContainer(); + } else { + console.log('✗ Container mounts failed\n'); + process.exit(1); + } + }); +} + +function testInteractiveContainer() { + console.log('5. Testing interactive container (will timeout after 10 seconds)...'); + const hostAidevDir = dirname(__dirname); + const hostWorkspace = process.cwd(); + + const interactiveTest = spawn('docker', [ + 'run', '--rm', '-it', + '-v', `${hostWorkspace}:/workspace:rw`, + '-v', `${hostAidevDir}:/aidev:ro`, + 'aidev:latest', + 'sleep', '5' + ], { stdio: 'inherit' }); + + const timeout = setTimeout(() => { + console.log('\nTimeout reached, killing container...'); + interactiveTest.kill('SIGTERM'); + }, 10000); + + interactiveTest.on('close', (code, signal) => { + clearTimeout(timeout); + console.log(`Interactive test completed with code ${code}, signal ${signal}`); + console.log('\n=== Debug Complete ==='); + console.log('If all tests passed, the issue may be with the aidev command itself inside the container.'); + console.log('Try running with the updated debugging output to see where it hangs.'); + }); +} + +debugDocker(); \ No newline at end of file diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh new file mode 100755 index 0000000..4cd4c91 --- /dev/null +++ b/scripts/build-docker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker build --platform=linux/amd64 -t aidev:latest . diff --git a/src/cli.ts b/src/cli.ts index 38b91f9..f229d2e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,10 @@ -import { exec } from 'child_process' +import { exec, spawn } from 'child_process' import EventEmitter from 'events' +import { ChildProcess } from 'node:child_process' +import { dirname } from 'node:path' import { Transform, TransformCallback } from 'node:stream' import readline, { CompleterResult } from 'readline' +import { Subprocess } from 'bun' import { program } from 'commander' import { EventSource } from 'eventsource' import { completer } from './chat/completer' @@ -13,7 +16,8 @@ import { createClient, registerContextListeners } from './mcp/client/client' import { registerTools } from './mcp/client/tools/tools' import { ChatProviders, initChatProviders } from './providers/chat_providers' import { EmbeddingsProviders, initEmbeddingsProviders } from './providers/embeddings_providers' -import { getPreferences, Preferences } from './providers/preferences' +import { keyDir } from './providers/keys' +import { getPreferences, Preferences, preferencesDir } from './providers/preferences' import { getRules } from './rules/loader' import { Rule } from './rules/types' import { buildSystemPrompt } from './system' @@ -57,28 +61,53 @@ async function main() { const yoloDescription = 'Skip user confirmation for potentially dangerous operations like file writing and shell execution.' + const dockerFlags = '--docker' + const dockerDescription = 'Run in a Docker container with the current workspace mounted into it.' + program .option(historyFlags, historyDescription) .option(portFlags, portDescription) .option(cwdFlags, cwdDescription) .option(yoloFlags, yoloDescription) .option(oneShotFlags, oneShotDescription) + .option(dockerFlags, dockerDescription) .action(options => { - if (options.cwd) { - process.chdir(options.cwd) + if (options.docker) { + if (!options.oneShot) { + throw new Error('The --one-shot option requires a prompt string.') + } + if (!options.yolo) { + throw new Error('The --yolo option is required when using --docker.') + } + if (options.port) { + throw new Error('The --port option is not supported when using --docker.') + } + + console.log('invoking docker...') + + runInDocker(options).catch(error => { + console.error('Error running in Docker:', error) + process.exit(1) + }) + } else { + console.log('running normally...') + + if (options.cwd) { + process.chdir(options.cwd) + } + + chat( + preferences, + rules, + providers, + embeddingsClients, + tracker, + options.history, + options.port, + options.oneShot, + options.yolo, + ) } - - chat( - preferences, - rules, - providers, - embeddingsClients, - tracker, - options.history, - options.port, - options.oneShot, - options.yolo, - ) }) program.parse(process.argv) @@ -98,6 +127,101 @@ async function chat( let context: ChatContext if (!process.stdin.setRawMode) { + if (oneShot && yolo) { + console.log('ok doing this weirdo route') + + // FAKE + const interruptHandler = { + withInterruptHandler: (f: (signal: AbortSignal) => Promise): Promise => { + return f(new AbortController().signal) + }, + } + + // FAKE + const prompter = { + question: (): Promise => Promise.reject('user input not allowed in this context'), + choice: (): Promise => Promise.reject('user input not allowed in this context'), + options: (): Promise => Promise.reject('user input not allowed in this context'), + } + + // readline.emitKeypressEvents(process.stdin) + // process.stdin.setRawMode(true) + // const filter = new ShiftEnterFilter() + // process.stdin.pipe(filter) + + // const rl = readline.createInterface({ + // input: filter, + // output: process.stdout, + // terminal: true, + // completer: (line: string, callback: (err?: null | Error, result?: CompleterResult) => void): void => { + // if (context) { + // completer(context, line).then(result => callback(undefined, result)) + // } + // }, + // }) + + // filter.on('shiftenter', () => { + // ;(rl as unknown as { _insertString: (s: string) => void })._insertString('\n') + // }) + + const client = await createClient(port) + const system = await buildSystemPrompt(preferences) + const contextStateManager = await createContextState() + await registerTools(client) + + try { + // const interruptHandler = createInterruptHandler(rl) + // const interruptInputOptions = rootInterruptHandlerOptions(rl) + // const prompter = createPrompter( + // rl, + // interruptHandler, + // attentionGetter(preferences.attentionCommand ?? defaultAttentionCommand), + // ) + + const provider = await providers.createProvider({ + contextState: contextStateManager, + modelName: preferences.defaultModel, + system, + }) + + context = { + preferences, + rules, + providers, + embeddingsClients, + tracker, + interruptHandler, + prompter, + provider, + events: new EventEmitter(), + contextStateManager, + yolo, + } + + await registerContextListeners(context, client) + + if (historyFilename) { + await loadHistory(context, historyFilename) + } + + const modelName = `${context.provider.modelName} (${context.provider.providerName})` + console.log(`${historyFilename ? 'Resuming' : 'Beginning'} session with ${modelName}...\n`) + + console.log('a') + await interruptHandler.withInterruptHandler(() => handle(context, oneShot)) + console.log('b') + + return + } finally { + console.log('c') + // process.stdin.unpipe(filter) + // filter.destroy() + // rl.close() + contextStateManager.dispose() + client?.close() + } + } + throw new Error('chat command is not supported in this environment.') } @@ -275,4 +399,138 @@ function attentionGetter(command: string): () => void { } } +async function runInDocker(options: any) { + const dockerArgs = buildDockerCommand(options) + + // Generate a unique container name for cleanup + const containerName = `aidev-${Date.now()}-${Math.random().toString(36).substring(7)}` + const fullDockerArgs = ['docker', 'run', '--name', containerName, ...dockerArgs] + + let subprocess: any = null + let cleanupCalled = false + + // Cleanup function + const cleanup = async () => { + if (cleanupCalled) return + cleanupCalled = true + + try { + // Kill the subprocess if still running + if (subprocess && !subprocess.killed) { + subprocess.kill() + } + + // Stop and remove container + const stopProc = Bun.spawn(['docker', 'stop', containerName], { + stdout: 'pipe', + stderr: 'pipe', + }) + await stopProc.exited + + const rmProc = Bun.spawn(['docker', 'rm', '-f', containerName], { + stdout: 'pipe', + stderr: 'pipe', + }) + await rmProc.exited + } catch { + // Ignore cleanup errors + } + } + + // Set up signal handlers + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] + const signalHandlers: Array<() => void> = [] + + for (const signal of signals) { + const handler = () => { + cleanup().then(() => { + process.exit(128 + (signal === 'SIGINT' ? 2 : 15)) + }) + } + signalHandlers.push(handler) + process.on(signal, handler) + } + + try { + console.log(fullDockerArgs.join(' ')) + // Spawn the Docker process + subprocess = Bun.spawn(fullDockerArgs, { + stdout: 'pipe', + stderr: 'pipe', + stdin: 'inherit', // Allow interactive input + }) + + // Stream output in real-time + const stdoutChunks: Uint8Array[] = [] + const stderrChunks: Uint8Array[] = [] + + // Handle stdout + const stdoutPromise = (async () => { + for await (const chunk of subprocess.stdout) { + stdoutChunks.push(chunk) + process.stdout.write(chunk) + } + })() + + // Handle stderr + const stderrPromise = (async () => { + for await (const chunk of subprocess.stderr) { + stderrChunks.push(chunk) + process.stderr.write(chunk) + } + })() + + // Wait for completion + const [exitCode] = await Promise.all([subprocess.exited, stdoutPromise, stderrPromise]) + console.log({ exitCode }) + + // Check exit code + if (exitCode !== 0) { + const stderr = Buffer.concat(stderrChunks).toString() + const error = new Error(`Docker container exited with code ${exitCode}`) + ;(error as any).exitCode = exitCode + ;(error as any).stderr = stderr + throw error + } + } catch (error) { + // Clean up on any error + await cleanup() + throw error + } finally { + // Remove signal handlers + let i = 0 + for (const signal of signals) { + process.off(signal, signalHandlers[i++]) + } + } +} + +const dockerImage = 'aidev:latest' +const containerAidevDir = '/aidev' +const containerConfigDir = '/root/.config/aidev' +const containerWorkspace = '/workspace' +const containerKeyDir = `${containerConfigDir}/keys` +const containerPreferencesDir = `${containerConfigDir}/preferences` + +function buildDockerCommand(options: any) { + const hostAidevDir = dirname(__dirname) + const hostWorkspace = options.cwd || process.cwd() + + return [ + '--rm', + ...['-v', `${hostWorkspace}:${containerWorkspace}:rw`], + ...['-v', `${hostAidevDir}:${containerAidevDir}:ro`], + ...['-v', `${keyDir()}:${containerKeyDir}:ro`], + ...['-v', `${preferencesDir()}:${containerPreferencesDir}:ro`], + ...['-e', `AIDEV_KEY_DIR=${containerKeyDir}`], + ...['-e', `AIDEV_PREFERENCES_DIR=${containerPreferencesDir}`], + dockerImage, + '--', + ...['--one-shot', options.oneShot], + ...['--yolo'], + ...['--cwd', containerWorkspace], + ...(options.history ? ['--history', options.history] : []), + ] +} + main() diff --git a/src/providers/keys.ts b/src/providers/keys.ts index 434bd09..ab72e32 100644 --- a/src/providers/keys.ts +++ b/src/providers/keys.ts @@ -10,7 +10,7 @@ function keyPath(name: string): string { return path.join(keyDir(), `${name.toLowerCase()}.key`) } -function keyDir(): string { +export function keyDir(): string { return process.env['AIDEV_KEY_DIR'] || path.join(configDir(), 'keys') } diff --git a/src/providers/preferences.ts b/src/providers/preferences.ts index b8d81bc..40ea72b 100644 --- a/src/providers/preferences.ts +++ b/src/providers/preferences.ts @@ -77,7 +77,7 @@ async function preferencesPath(): Promise { return defaultPreferencesPath } -function preferencesDir(): string { +export function preferencesDir(): string { return process.env['AIDEV_PREFERENCES_DIR'] || configDir() } diff --git a/src/util/progress/progress.ts b/src/util/progress/progress.ts index e893971..e48351e 100644 --- a/src/util/progress/progress.ts +++ b/src/util/progress/progress.ts @@ -13,6 +13,26 @@ export type ProgressOptions = { } export async function withProgress(f: ProgressSubject, options: ProgressOptions): Promise> { + if (!process.stdin.setRawMode) { + console.log('ok weirdo way') + // setInterval(() => console.log('tick'), 1000) + let snapshot: T | undefined + console.log(options.progress(undefined)) + try { + const response = await f(latest => { + snapshot = latest + // console.log(options.progress(snapshot)) + }) + console.log(options.success(response)) + return { ok: true, response } + } catch (error: any) { + console.log(options.failure(snapshot, error)) + return { ok: false, error } + } finally { + console.log('DONE') + } + } + const spinner = ora({ text: options.progress(undefined), discardStdin: false,