From af394183e6f62debc65b6e9f4f61345df726fc05 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 5 Jan 2026 18:13:14 -0500 Subject: [PATCH 1/5] feat: add Cursor CLI installation attempts documentation and enhance Docker setup - Introduced a new markdown file summarizing various attempts to install the Cursor CLI in Docker, detailing approaches, results, and key learnings. - Updated Dockerfile to ensure proper installation of Cursor CLI for the non-root user, including necessary PATH adjustments for interactive shells. - Enhanced entrypoint script to manage OAuth tokens for both Claude and Cursor CLIs, ensuring correct permissions and directory setups. - Added scripts for extracting OAuth tokens from macOS Keychain and Linux JSON files for seamless integration with Docker. - Updated docker-compose files to support persistent storage for CLI configurations and authentication tokens. These changes improve the development workflow and provide clear guidance on CLI installation and authentication processes. --- Dockerfile | 71 ++++++++---- .../settings-view/hooks/use-cli-status.ts | 44 +++++++- dev.mjs | 104 +++++++++++++++++- docker-compose.override.yml.example | 18 +++ docker-compose.yml | 20 ++++ docker-entrypoint.sh | 50 +++++++-- docs/docker-isolation.md | 63 ++++++++++- libs/platform/src/system-paths.ts | 8 +- scripts/get-claude-token.sh | 34 ++++++ scripts/get-cursor-token.sh | 69 ++++++++++++ start.mjs | 104 +++++++++++++++++- 11 files changed, 536 insertions(+), 49 deletions(-) create mode 100755 scripts/get-claude-token.sh create mode 100755 scripts/get-cursor-token.sh diff --git a/Dockerfile b/Dockerfile index 84ddc49a..d4675da5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,10 +8,12 @@ # ============================================================================= # BASE STAGE - Common setup for all builds (DRY: defined once, used by all) # ============================================================================= -FROM node:22-alpine AS base +FROM node:22-slim AS base # Install build dependencies for native modules (node-pty) -RUN apk add --no-cache python3 make g++ +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -51,32 +53,59 @@ RUN npm run build:packages && npm run build --workspace=apps/server # ============================================================================= # SERVER PRODUCTION STAGE # ============================================================================= -FROM node:22-alpine AS server +FROM node:22-slim AS server -# Install git, curl, bash (for terminal), su-exec (for user switching), and GitHub CLI (pinned version, multi-arch) -RUN apk add --no-cache git curl bash su-exec && \ - GH_VERSION="2.63.2" && \ - ARCH=$(uname -m) && \ - case "$ARCH" in \ +# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch) +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl bash gosu ca-certificates \ + && GH_VERSION="2.63.2" \ + && ARCH=$(uname -m) \ + && case "$ARCH" in \ x86_64) GH_ARCH="amd64" ;; \ aarch64|arm64) GH_ARCH="arm64" ;; \ *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ - esac && \ - curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \ - tar -xzf gh.tar.gz && \ - mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \ - rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} + esac \ + && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \ + && tar -xzf gh.tar.gz \ + && mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \ + && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \ + && rm -rf /var/lib/apt/lists/* -# Install Claude CLI globally +# Install Claude CLI globally (available to all users via npm global bin) RUN npm install -g @anthropic-ai/claude-code -WORKDIR /app +# Create non-root user with home directory BEFORE installing Cursor CLI +RUN groupadd -g 1001 automaker && \ + useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \ + mkdir -p /home/automaker/.local/bin && \ + mkdir -p /home/automaker/.cursor && \ + chown -R automaker:automaker /home/automaker && \ + chmod 700 /home/automaker/.cursor -# Create non-root user with home directory -RUN addgroup -g 1001 -S automaker && \ - adduser -S automaker -u 1001 -h /home/automaker && \ - mkdir -p /home/automaker && \ - chown automaker:automaker /home/automaker +# Install Cursor CLI as the automaker user +# Set HOME explicitly and install to /home/automaker/.local/bin/ +USER automaker +ENV HOME=/home/automaker +RUN curl https://cursor.com/install -fsS | bash && \ + echo "=== Checking Cursor CLI installation ===" && \ + ls -la /home/automaker/.local/bin/ && \ + echo "=== PATH is: $PATH ===" && \ + (which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)" +USER root + +# Add PATH to profile so it's available in all interactive shells (for login shells) +RUN mkdir -p /etc/profile.d && \ + echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \ + chmod +x /etc/profile.d/cursor-cli.sh + +# Add to automaker's .bashrc for bash interactive shells +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \ + chown automaker:automaker /home/automaker/.bashrc + +# Also add to root's .bashrc since docker exec defaults to root +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc + +WORKDIR /app # Copy root package.json (needed for workspace resolution) COPY --from=server-builder /app/package*.json ./ @@ -111,6 +140,8 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh ENV PORT=3008 ENV DATA_DIR=/data ENV HOME=/home/automaker +# Add user's local bin to PATH for cursor-agent +ENV PATH="/home/automaker/.local/bin:${PATH}" # Expose port EXPOSE 3008 diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts index ce067e17..ac97040a 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts @@ -95,7 +95,7 @@ export function useCliStatus() { checkCliStatus(); }, [setClaudeAuthStatus]); - // Refresh Claude CLI status + // Refresh Claude CLI status and auth status const handleRefreshClaudeCli = useCallback(async () => { setIsCheckingClaudeCli(true); try { @@ -104,12 +104,52 @@ export function useCliStatus() { const status = await api.checkClaudeCli(); setClaudeCliStatus(status); } + // Also refresh auth status + if (api?.setup?.getClaudeStatus) { + try { + const result = await api.setup.getClaudeStatus(); + if (result.success && result.auth) { + const auth = result.auth as typeof result.auth & { + oauthTokenValid?: boolean; + apiKeyValid?: boolean; + }; + const validMethods = [ + 'oauth_token_env', + 'oauth_token', + 'api_key', + 'api_key_env', + 'credentials_file', + 'cli_authenticated', + 'none', + ] as const; + type AuthMethod = (typeof validMethods)[number]; + const method: AuthMethod = validMethods.includes(auth.method as AuthMethod) + ? (auth.method as AuthMethod) + : auth.authenticated + ? 'api_key' + : 'none'; + const authStatus = { + authenticated: auth.authenticated, + method, + hasCredentialsFile: auth.hasCredentialsFile ?? false, + oauthTokenValid: + auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken, + apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey, + hasEnvOAuthToken: auth.hasEnvOAuthToken, + hasEnvApiKey: auth.hasEnvApiKey, + }; + setClaudeAuthStatus(authStatus); + } + } catch (error) { + logger.error('Failed to refresh Claude auth status:', error); + } + } } catch (error) { logger.error('Failed to refresh Claude CLI status:', error); } finally { setIsCheckingClaudeCli(false); } - }, []); + }, [setClaudeAuthStatus]); return { claudeCliStatus, diff --git a/dev.mjs b/dev.mjs index e6a44c30..ea549c65 100644 --- a/dev.mjs +++ b/dev.mjs @@ -12,6 +12,8 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +import { statSync } from 'fs'; +import { execSync } from 'child_process'; import { createRestrictedFs, @@ -45,6 +47,89 @@ const processes = { docker: null, }; +/** + * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes + */ +function shouldRebuildDockerImages() { + try { + const dockerfilePath = path.join(__dirname, 'Dockerfile'); + const packageJsonPath = path.join(__dirname, 'package.json'); + + // Get modification times of source files + const dockerfileMtime = statSync(dockerfilePath).mtimeMs; + const packageJsonMtime = statSync(packageJsonPath).mtimeMs; + const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); + + // Get image names from docker-compose config + let serverImageName, uiImageName; + try { + const composeConfig = execSync('docker compose config --format json', { + encoding: 'utf-8', + cwd: __dirname, + }); + const config = JSON.parse(composeConfig); + + // Docker Compose generates image names as _ + // Get project name from config or default to directory name + const projectName = + config.name || + path + .basename(__dirname) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + serverImageName = `${projectName}_server`; + uiImageName = `${projectName}_ui`; + } catch (error) { + // Fallback to default naming convention + const projectName = path + .basename(__dirname) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + serverImageName = `${projectName}_server`; + uiImageName = `${projectName}_ui`; + } + + // Check if images exist and get their creation times + let needsRebuild = false; + + try { + // Check server image + const serverImageInfo = execSync( + `docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, + { encoding: 'utf-8', cwd: __dirname } + ).trim(); + + // Check UI image + const uiImageInfo = execSync( + `docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, + { encoding: 'utf-8', cwd: __dirname } + ).trim(); + + // If either image doesn't exist, we need to rebuild + if (!serverImageInfo || !uiImageInfo) { + return true; + } + + // Parse image creation times (ISO 8601 format) + const serverCreated = new Date(serverImageInfo).getTime(); + const uiCreated = new Date(uiImageInfo).getTime(); + const oldestImageTime = Math.min(serverCreated, uiCreated); + + // If source files are newer than images, rebuild + needsRebuild = latestSourceMtime > oldestImageTime; + } catch (error) { + // If images don't exist or inspect fails, rebuild + needsRebuild = true; + } + + return needsRebuild; + } catch (error) { + // If we can't check, err on the side of rebuilding + log('Could not check Docker image status, will rebuild to be safe', 'yellow'); + return true; + } +} + /** * Install Playwright browsers (dev-only dependency) */ @@ -172,9 +257,16 @@ async function main() { } else if (choice === '3') { console.log(''); log('Launching Docker Container (Isolated Mode)...', 'blue'); - log('Starting Docker containers...', 'yellow'); - log('Note: Containers will only rebuild if images are missing.', 'yellow'); - log('To force a rebuild, run: docker compose up --build', 'yellow'); + + // Check if Dockerfile or package.json changed and rebuild if needed + const needsRebuild = shouldRebuildDockerImages(); + const buildFlag = needsRebuild ? ['--build'] : []; + + if (needsRebuild) { + log('Dockerfile or package.json changed - rebuilding images...', 'yellow'); + } else { + log('Starting Docker containers...', 'yellow'); + } console.log(''); // Check if ANTHROPIC_API_KEY is set @@ -185,9 +277,9 @@ async function main() { console.log(''); } - // Start containers with docker-compose (without --build to preserve volumes) - // Images will only be built if they don't exist - processes.docker = crossSpawn('docker', ['compose', 'up'], { + // Start containers with docker-compose + // Will rebuild if Dockerfile or package.json changed + processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], { stdio: 'inherit', cwd: __dirname, env: { diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 611ff588..b4ef6c47 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -4,8 +4,26 @@ services: # Mount your workspace directory to /projects inside the container # Example: mount your local /workspace to /projects inside the container - /Users/webdevcody/Workspace/automaker-workspace:/projects:rw + + # ===== CLI Authentication (Optional) ===== + # Mount host CLI credentials to avoid re-authenticating in container + + # Claude CLI - mount your ~/.claude directory (Linux/Windows) + # This shares your 'claude login' OAuth session with the container + # - ~/.claude:/home/automaker/.claude + + # Cursor CLI - mount your ~/.cursor directory (Linux/Windows) + # This shares your 'cursor-agent login' OAuth session with the container + # - ~/.cursor:/home/automaker/.cursor + environment: # Set root directory for all projects and file operations # Users can only create/open projects within this directory - ALLOWED_ROOT_DIRECTORY=/projects - NODE_ENV=development + + # ===== macOS Users ===== + # On macOS, OAuth tokens are stored in SQLite databases, not plain files. + # Extract your Cursor token with: ./scripts/get-cursor-token.sh + # Then set it here or in your .env file: + # - CURSOR_API_KEY=${CURSOR_API_KEY:-} diff --git a/docker-compose.yml b/docker-compose.yml index b9e51abf..227450ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,17 @@ services: # Required - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + # Optional - Claude CLI OAuth credentials (for macOS users) + # Extract with: ./scripts/get-claude-token.sh + # This writes the OAuth tokens to ~/.claude/.credentials.json in the container + - CLAUDE_OAUTH_CREDENTIALS=${CLAUDE_OAUTH_CREDENTIALS:-} + + # Optional - Cursor CLI OAuth token (extract from host with the command shown below) + # macOS: ./scripts/get-cursor-token.sh (extracts from Keychain) + # Linux: jq -r '.accessToken' ~/.config/cursor/auth.json + # Note: cursor-agent stores its OAuth tokens separately from Cursor IDE + - CURSOR_AUTH_TOKEN=${CURSOR_AUTH_TOKEN:-} + # Optional - authentication, one will generate if left blank - AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-} @@ -63,6 +74,10 @@ services: # This allows 'claude login' authentication to persist between restarts - automaker-claude-config:/home/automaker/.claude + # Persist Cursor CLI configuration and authentication across container restarts + # This allows 'cursor-agent login' authentication to persist between restarts + - automaker-cursor-config:/home/automaker/.cursor + # NO host directory mounts - container cannot access your laptop files # If you need to work on a project, create it INSIDE the container # or use a separate docker-compose override file @@ -81,3 +96,8 @@ volumes: name: automaker-claude-config # Named volume for Claude CLI OAuth session keys and configuration # Persists authentication across container restarts + + automaker-cursor-config: + name: automaker-cursor-config + # Named volume for Cursor CLI configuration and authentication + # Persists cursor-agent login authentication across container restarts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 6537a66e..a13c4553 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,19 +1,51 @@ #!/bin/sh set -e -# Fix permissions on Claude CLI config directory if it exists +# Ensure Claude CLI config directory exists with correct permissions +if [ ! -d "/home/automaker/.claude" ]; then + mkdir -p /home/automaker/.claude +fi + +# If CLAUDE_OAUTH_CREDENTIALS is set, write it to the credentials file +# This allows passing OAuth tokens from host (especially macOS where they're in Keychain) +if [ -n "$CLAUDE_OAUTH_CREDENTIALS" ]; then + echo "$CLAUDE_OAUTH_CREDENTIALS" > /home/automaker/.claude/.credentials.json + chmod 600 /home/automaker/.claude/.credentials.json +fi + +# Fix permissions on Claude CLI config directory +chown -R automaker:automaker /home/automaker/.claude +chmod 700 /home/automaker/.claude + +# Fix permissions on Cursor CLI config directory if it exists # This handles the case where a volume is mounted and owned by root -if [ -d "/home/automaker/.claude" ]; then - chown -R automaker:automaker /home/automaker/.claude - chmod -R 755 /home/automaker/.claude +if [ -d "/home/automaker/.cursor" ]; then + chown -R automaker:automaker /home/automaker/.cursor + chmod -R 700 /home/automaker/.cursor fi # Ensure the directory exists with correct permissions if volume is empty -if [ ! -d "/home/automaker/.claude" ]; then - mkdir -p /home/automaker/.claude - chown automaker:automaker /home/automaker/.claude - chmod 755 /home/automaker/.claude +if [ ! -d "/home/automaker/.cursor" ]; then + mkdir -p /home/automaker/.cursor + chown automaker:automaker /home/automaker/.cursor + chmod 700 /home/automaker/.cursor +fi + +# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file +# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage +# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent +if [ -n "$CURSOR_AUTH_TOKEN" ]; then + CURSOR_CONFIG_DIR="/home/automaker/.config/cursor" + mkdir -p "$CURSOR_CONFIG_DIR" + # Write auth.json with the access token + cat > "$CURSOR_CONFIG_DIR/auth.json" << EOF +{ + "accessToken": "$CURSOR_AUTH_TOKEN" +} +EOF + chmod 600 "$CURSOR_CONFIG_DIR/auth.json" + chown -R automaker:automaker /home/automaker/.config fi # Switch to automaker user and execute the command -exec su-exec automaker "$@" +exec gosu automaker "$@" diff --git a/docs/docker-isolation.md b/docs/docker-isolation.md index 5ebd4c71..af190d9d 100644 --- a/docs/docker-isolation.md +++ b/docs/docker-isolation.md @@ -57,10 +57,63 @@ docker-compose -f docker-compose.yml -f docker-compose.project.yml up -d **Tip**: Use `:ro` (read-only) when possible for extra safety. +## CLI Authentication (macOS) + +On macOS, OAuth tokens are stored in Keychain (Claude) and SQLite (Cursor). Use these scripts to extract and pass them to the container: + +### Claude CLI + +```bash +# Extract and add to .env +echo "CLAUDE_OAUTH_CREDENTIALS=$(./scripts/get-claude-token.sh)" >> .env +``` + +### Cursor CLI + +```bash +# Extract and add to .env (extracts from macOS Keychain) +echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env +``` + +**Note**: The cursor-agent CLI stores its OAuth tokens separately from the Cursor IDE: + +- **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`) +- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` + +### Apply to container + +```bash +# Restart with new credentials +docker-compose down && docker-compose up -d +``` + +**Note**: Tokens expire periodically. If you get authentication errors, re-run the extraction scripts. + +## CLI Authentication (Linux/Windows) + +On Linux/Windows, cursor-agent stores credentials in files, so you can either: + +**Option 1: Extract tokens to environment variables (recommended)** + +```bash +# Linux: Extract tokens to .env +echo "CURSOR_AUTH_TOKEN=$(jq -r '.accessToken' ~/.config/cursor/auth.json)" >> .env +``` + +**Option 2: Bind mount credential directories directly** + +```yaml +# In docker-compose.override.yml +volumes: + - ~/.claude:/home/automaker/.claude + - ~/.config/cursor:/home/automaker/.config/cursor +``` + ## Troubleshooting -| Problem | Solution | -| --------------------- | -------------------------------------------------------------------------------------------- | -| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. | -| Can't access web UI | Verify container is running with `docker ps \| grep automaker` | -| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` | +| Problem | Solution | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. | +| Can't access web UI | Verify container is running with `docker ps \| grep automaker` | +| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` | +| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. | diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 2824d623..6011e559 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -800,8 +800,14 @@ export async function getClaudeAuthIndicators(): Promise { const content = await systemPathReadFile(credPath); const credentials = JSON.parse(content); result.hasCredentialsFile = true; + // Support multiple credential formats: + // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } + // 2. Legacy format: { oauth_token } or { access_token } + // 3. API key format: { api_key } + const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; + const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); result.credentials = { - hasOAuthToken: !!(credentials.oauth_token || credentials.access_token), + hasOAuthToken: hasClaudeOauth || hasLegacyOauth, hasApiKey: !!credentials.api_key, }; break; diff --git a/scripts/get-claude-token.sh b/scripts/get-claude-token.sh new file mode 100755 index 00000000..1ebdd0cd --- /dev/null +++ b/scripts/get-claude-token.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Extract Claude OAuth token from macOS Keychain for use in Docker container +# Usage: ./scripts/get-claude-token.sh +# or: export CLAUDE_OAUTH_TOKEN=$(./scripts/get-claude-token.sh) + +set -e + +# Only works on macOS (uses security command for Keychain access) +if [[ "$OSTYPE" != "darwin"* ]]; then + echo "Error: This script only works on macOS." >&2 + echo "On Linux, mount ~/.claude directory directly instead." >&2 + exit 1 +fi + +# Check if security command exists +if ! command -v security &> /dev/null; then + echo "Error: 'security' command not found." >&2 + exit 1 +fi + +# Get the current username +USERNAME=$(whoami) + +# Extract credentials from Keychain +CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$USERNAME" -w 2>/dev/null) + +if [ -z "$CREDS" ]; then + echo "Error: No Claude credentials found in Keychain." >&2 + echo "Make sure you've logged in with 'claude login' first." >&2 + exit 1 +fi + +# Output the full credentials JSON (contains accessToken and refreshToken) +echo "$CREDS" diff --git a/scripts/get-cursor-token.sh b/scripts/get-cursor-token.sh new file mode 100755 index 00000000..912cce6b --- /dev/null +++ b/scripts/get-cursor-token.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Extract Cursor CLI OAuth token from host machine for use in Docker container +# +# IMPORTANT: This extracts the cursor-agent CLI OAuth token, NOT the Cursor IDE token. +# cursor-agent stores tokens in macOS Keychain (not SQLite like the IDE). +# +# Usage: ./scripts/get-cursor-token.sh +# or: export CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh) +# +# For Docker: echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env + +set -e + +# Determine platform and extract token accordingly +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: cursor-agent stores OAuth tokens in Keychain + # Service: cursor-access-token, Account: cursor-user + + if ! command -v security &> /dev/null; then + echo "Error: 'security' command not found." >&2 + exit 1 + fi + + # Extract access token from Keychain + TOKEN=$(security find-generic-password -a "cursor-user" -s "cursor-access-token" -w 2>/dev/null) + + if [ -z "$TOKEN" ]; then + echo "Error: No Cursor CLI token found in Keychain." >&2 + echo "Make sure you've logged in with 'cursor-agent login' first." >&2 + exit 1 + fi + +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + # Linux: cursor-agent stores OAuth tokens in a JSON file + # Default location: ~/.config/cursor/auth.json + # Or: $XDG_CONFIG_HOME/cursor/auth.json + + if [ -n "$XDG_CONFIG_HOME" ]; then + AUTH_FILE="$XDG_CONFIG_HOME/cursor/auth.json" + else + AUTH_FILE="$HOME/.config/cursor/auth.json" + fi + + if [ ! -f "$AUTH_FILE" ]; then + echo "Error: Cursor auth file not found at: $AUTH_FILE" >&2 + echo "Make sure you've logged in with 'cursor-agent login' first." >&2 + exit 1 + fi + + # Check if jq is available + if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed." >&2 + echo "Install it with: apt install jq" >&2 + exit 1 + fi + + TOKEN=$(jq -r '.accessToken // empty' "$AUTH_FILE" 2>/dev/null) + + if [ -z "$TOKEN" ]; then + echo "Error: No access token found in $AUTH_FILE" >&2 + exit 1 + fi +else + echo "Error: Unsupported platform: $OSTYPE" >&2 + exit 1 +fi + +# Output the token +echo "$TOKEN" diff --git a/start.mjs b/start.mjs index 2eb1739c..80ed4746 100755 --- a/start.mjs +++ b/start.mjs @@ -19,6 +19,8 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +import { statSync } from 'fs'; +import { execSync } from 'child_process'; import { createRestrictedFs, log, @@ -54,6 +56,89 @@ const processes = { docker: null, }; +/** + * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes + */ +function shouldRebuildDockerImages() { + try { + const dockerfilePath = path.join(__dirname, 'Dockerfile'); + const packageJsonPath = path.join(__dirname, 'package.json'); + + // Get modification times of source files + const dockerfileMtime = statSync(dockerfilePath).mtimeMs; + const packageJsonMtime = statSync(packageJsonPath).mtimeMs; + const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); + + // Get image names from docker-compose config + let serverImageName, uiImageName; + try { + const composeConfig = execSync('docker compose config --format json', { + encoding: 'utf-8', + cwd: __dirname, + }); + const config = JSON.parse(composeConfig); + + // Docker Compose generates image names as _ + // Get project name from config or default to directory name + const projectName = + config.name || + path + .basename(__dirname) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + serverImageName = `${projectName}_server`; + uiImageName = `${projectName}_ui`; + } catch (error) { + // Fallback to default naming convention + const projectName = path + .basename(__dirname) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + serverImageName = `${projectName}_server`; + uiImageName = `${projectName}_ui`; + } + + // Check if images exist and get their creation times + let needsRebuild = false; + + try { + // Check server image + const serverImageInfo = execSync( + `docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, + { encoding: 'utf-8', cwd: __dirname } + ).trim(); + + // Check UI image + const uiImageInfo = execSync( + `docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, + { encoding: 'utf-8', cwd: __dirname } + ).trim(); + + // If either image doesn't exist, we need to rebuild + if (!serverImageInfo || !uiImageInfo) { + return true; + } + + // Parse image creation times (ISO 8601 format) + const serverCreated = new Date(serverImageInfo).getTime(); + const uiCreated = new Date(uiImageInfo).getTime(); + const oldestImageTime = Math.min(serverCreated, uiCreated); + + // If source files are newer than images, rebuild + needsRebuild = latestSourceMtime > oldestImageTime; + } catch (error) { + // If images don't exist or inspect fails, rebuild + needsRebuild = true; + } + + return needsRebuild; + } catch (error) { + // If we can't check, err on the side of rebuilding + log('Could not check Docker image status, will rebuild to be safe', 'yellow'); + return true; + } +} + /** * Build all production artifacts */ @@ -231,9 +316,16 @@ async function main() { } else if (choice === '3') { console.log(''); log('Launching Docker Container (Isolated Mode)...', 'blue'); - log('Starting Docker containers...', 'yellow'); - log('Note: Containers will only rebuild if images are missing.', 'yellow'); - log('To force a rebuild, run: docker compose up --build', 'yellow'); + + // Check if Dockerfile or package.json changed and rebuild if needed + const needsRebuild = shouldRebuildDockerImages(); + const buildFlag = needsRebuild ? ['--build'] : []; + + if (needsRebuild) { + log('Dockerfile or package.json changed - rebuilding images...', 'yellow'); + } else { + log('Starting Docker containers...', 'yellow'); + } console.log(''); // Check if ANTHROPIC_API_KEY is set @@ -244,9 +336,9 @@ async function main() { console.log(''); } - // Start containers with docker-compose (without --build to preserve volumes) - // Images will only be built if they don't exist - processes.docker = crossSpawn('docker', ['compose', 'up'], { + // Start containers with docker-compose + // Will rebuild if Dockerfile or package.json changed + processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], { stdio: 'inherit', cwd: __dirname, env: { From 2a0719e00c07cc1ae34ae353d977ca6de7dc4302 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 6 Jan 2026 00:58:31 +0100 Subject: [PATCH 2/5] refactor: move logger initialization outside of useCliStatus hook - Moved the logger creation outside the hook to prevent infinite re-renders. - Updated dependencies in the checkStatus function to remove logger from the dependency array. These changes enhance performance and maintainability of the useCliStatus hook. --- .../src/components/views/setup-view/hooks/use-cli-status.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts index 43c8a6f6..f543f34f 100644 --- a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts +++ b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts @@ -8,6 +8,9 @@ interface UseCliStatusOptions { setAuthStatus: (status: any) => void; } +// Create logger once outside the hook to prevent infinite re-renders +const logger = createLogger('CliStatus'); + export function useCliStatus({ cliType, statusApi, @@ -15,7 +18,6 @@ export function useCliStatus({ setAuthStatus, }: UseCliStatusOptions) { const [isChecking, setIsChecking] = useState(false); - const logger = createLogger('CliStatus'); const checkStatus = useCallback(async () => { logger.info(`Starting status check for ${cliType}...`); @@ -66,7 +68,7 @@ export function useCliStatus({ } finally { setIsChecking(false); } - }, [cliType, statusApi, setCliStatus, setAuthStatus, logger]); + }, [cliType, statusApi, setCliStatus, setAuthStatus]); return { isChecking, checkStatus }; } From bc5a36c5f4b893ad4444d143f43de0cdc5c47d65 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 5 Jan 2026 21:28:42 -0500 Subject: [PATCH 3/5] feat: enhance project name sanitization and improve Docker image naming - Added a `sanitizeProjectName` function to ensure project names are safe for shell commands and Docker image names by converting them to lowercase and removing non-alphanumeric characters. - Updated `dev.mjs` and `start.mjs` to utilize the new sanitization function when determining Docker image names, enhancing security and consistency. - Refactored the Docker entrypoint script to ensure proper permissions for the Cursor CLI config directory, improving setup reliability. - Clarified documentation regarding the storage location of OAuth tokens for the Cursor CLI on Linux. These changes improve the robustness of the Docker setup and enhance the overall development workflow. --- .../settings-view/hooks/use-cli-status.ts | 134 +++++++----------- dev.mjs | 40 +++--- docker-entrypoint.sh | 14 +- docs/docker-isolation.md | 2 +- start.mjs | 40 +++--- 5 files changed, 96 insertions(+), 134 deletions(-) diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts index ac97040a..5afaf5f7 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts @@ -32,6 +32,53 @@ export function useCliStatus() { const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); + // Refresh Claude auth status from the server + const refreshAuthStatus = useCallback(async () => { + const api = getElectronAPI(); + if (!api?.setup?.getClaudeStatus) return; + + try { + const result = await api.setup.getClaudeStatus(); + if (result.success && result.auth) { + // Cast to extended type that includes server-added fields + const auth = result.auth as typeof result.auth & { + oauthTokenValid?: boolean; + apiKeyValid?: boolean; + }; + // Map server method names to client method types + // Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none + const validMethods = [ + 'oauth_token_env', + 'oauth_token', + 'api_key', + 'api_key_env', + 'credentials_file', + 'cli_authenticated', + 'none', + ] as const; + type AuthMethod = (typeof validMethods)[number]; + const method: AuthMethod = validMethods.includes(auth.method as AuthMethod) + ? (auth.method as AuthMethod) + : auth.authenticated + ? 'api_key' + : 'none'; // Default authenticated to api_key, not none + const authStatus = { + authenticated: auth.authenticated, + method, + hasCredentialsFile: auth.hasCredentialsFile ?? false, + oauthTokenValid: + auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken, + apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey, + hasEnvOAuthToken: auth.hasEnvOAuthToken, + hasEnvApiKey: auth.hasEnvApiKey, + }; + setClaudeAuthStatus(authStatus); + } + } catch (error) { + logger.error('Failed to refresh Claude auth status:', error); + } + }, [setClaudeAuthStatus]); + // Check CLI status on mount useEffect(() => { const checkCliStatus = async () => { @@ -48,52 +95,11 @@ export function useCliStatus() { } // Check Claude auth status (re-fetch on mount to ensure persistence) - if (api?.setup?.getClaudeStatus) { - try { - const result = await api.setup.getClaudeStatus(); - if (result.success && result.auth) { - // Cast to extended type that includes server-added fields - const auth = result.auth as typeof result.auth & { - oauthTokenValid?: boolean; - apiKeyValid?: boolean; - }; - // Map server method names to client method types - // Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none - const validMethods = [ - 'oauth_token_env', - 'oauth_token', - 'api_key', - 'api_key_env', - 'credentials_file', - 'cli_authenticated', - 'none', - ] as const; - type AuthMethod = (typeof validMethods)[number]; - const method: AuthMethod = validMethods.includes(auth.method as AuthMethod) - ? (auth.method as AuthMethod) - : auth.authenticated - ? 'api_key' - : 'none'; // Default authenticated to api_key, not none - const authStatus = { - authenticated: auth.authenticated, - method, - hasCredentialsFile: auth.hasCredentialsFile ?? false, - oauthTokenValid: - auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken, - apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey, - hasEnvOAuthToken: auth.hasEnvOAuthToken, - hasEnvApiKey: auth.hasEnvApiKey, - }; - setClaudeAuthStatus(authStatus); - } - } catch (error) { - logger.error('Failed to check Claude auth status:', error); - } - } + await refreshAuthStatus(); }; checkCliStatus(); - }, [setClaudeAuthStatus]); + }, [refreshAuthStatus]); // Refresh Claude CLI status and auth status const handleRefreshClaudeCli = useCallback(async () => { @@ -105,51 +111,13 @@ export function useCliStatus() { setClaudeCliStatus(status); } // Also refresh auth status - if (api?.setup?.getClaudeStatus) { - try { - const result = await api.setup.getClaudeStatus(); - if (result.success && result.auth) { - const auth = result.auth as typeof result.auth & { - oauthTokenValid?: boolean; - apiKeyValid?: boolean; - }; - const validMethods = [ - 'oauth_token_env', - 'oauth_token', - 'api_key', - 'api_key_env', - 'credentials_file', - 'cli_authenticated', - 'none', - ] as const; - type AuthMethod = (typeof validMethods)[number]; - const method: AuthMethod = validMethods.includes(auth.method as AuthMethod) - ? (auth.method as AuthMethod) - : auth.authenticated - ? 'api_key' - : 'none'; - const authStatus = { - authenticated: auth.authenticated, - method, - hasCredentialsFile: auth.hasCredentialsFile ?? false, - oauthTokenValid: - auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken, - apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey, - hasEnvOAuthToken: auth.hasEnvOAuthToken, - hasEnvApiKey: auth.hasEnvApiKey, - }; - setClaudeAuthStatus(authStatus); - } - } catch (error) { - logger.error('Failed to refresh Claude auth status:', error); - } - } + await refreshAuthStatus(); } catch (error) { logger.error('Failed to refresh Claude CLI status:', error); } finally { setIsCheckingClaudeCli(false); } - }, [setClaudeAuthStatus]); + }, [refreshAuthStatus]); return { claudeCliStatus, diff --git a/dev.mjs b/dev.mjs index ea549c65..f22a68e4 100644 --- a/dev.mjs +++ b/dev.mjs @@ -47,6 +47,14 @@ const processes = { docker: null, }; +/** + * Sanitize a project name to be safe for use in shell commands and Docker image names. + * Converts to lowercase and removes any characters that aren't alphanumeric. + */ +function sanitizeProjectName(name) { + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + /** * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes */ @@ -60,35 +68,27 @@ function shouldRebuildDockerImages() { const packageJsonMtime = statSync(packageJsonPath).mtimeMs; const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); - // Get image names from docker-compose config - let serverImageName, uiImageName; + // Get project name from docker-compose config, falling back to directory name + let projectName; try { const composeConfig = execSync('docker compose config --format json', { encoding: 'utf-8', cwd: __dirname, }); const config = JSON.parse(composeConfig); - - // Docker Compose generates image names as _ - // Get project name from config or default to directory name - const projectName = - config.name || - path - .basename(__dirname) - .toLowerCase() - .replace(/[^a-z0-9]/g, ''); - serverImageName = `${projectName}_server`; - uiImageName = `${projectName}_ui`; + projectName = config.name; } catch (error) { - // Fallback to default naming convention - const projectName = path - .basename(__dirname) - .toLowerCase() - .replace(/[^a-z0-9]/g, ''); - serverImageName = `${projectName}_server`; - uiImageName = `${projectName}_ui`; + // Fallback handled below } + // Sanitize project name (whether from config or fallback) + // This prevents command injection and ensures valid Docker image names + const sanitizedProjectName = sanitizeProjectName( + projectName || path.basename(__dirname) + ); + const serverImageName = `${sanitizedProjectName}_server`; + const uiImageName = `${sanitizedProjectName}_ui`; + // Check if images exist and get their creation times let needsRebuild = false; diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index a13c4553..017213dc 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -17,19 +17,13 @@ fi chown -R automaker:automaker /home/automaker/.claude chmod 700 /home/automaker/.claude -# Fix permissions on Cursor CLI config directory if it exists -# This handles the case where a volume is mounted and owned by root -if [ -d "/home/automaker/.cursor" ]; then - chown -R automaker:automaker /home/automaker/.cursor - chmod -R 700 /home/automaker/.cursor -fi - -# Ensure the directory exists with correct permissions if volume is empty +# Ensure Cursor CLI config directory exists with correct permissions +# This handles both: mounted volumes (owned by root) and empty directories if [ ! -d "/home/automaker/.cursor" ]; then mkdir -p /home/automaker/.cursor - chown automaker:automaker /home/automaker/.cursor - chmod 700 /home/automaker/.cursor fi +chown -R automaker:automaker /home/automaker/.cursor +chmod -R 700 /home/automaker/.cursor # If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file # On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage diff --git a/docs/docker-isolation.md b/docs/docker-isolation.md index af190d9d..eb8fe7e1 100644 --- a/docs/docker-isolation.md +++ b/docs/docker-isolation.md @@ -78,7 +78,7 @@ echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env **Note**: The cursor-agent CLI stores its OAuth tokens separately from the Cursor IDE: - **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`) -- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` +- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` (not `~/.cursor`) ### Apply to container diff --git a/start.mjs b/start.mjs index 80ed4746..29a6e04e 100755 --- a/start.mjs +++ b/start.mjs @@ -56,6 +56,14 @@ const processes = { docker: null, }; +/** + * Sanitize a project name to be safe for use in shell commands and Docker image names. + * Converts to lowercase and removes any characters that aren't alphanumeric. + */ +function sanitizeProjectName(name) { + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + /** * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes */ @@ -69,35 +77,27 @@ function shouldRebuildDockerImages() { const packageJsonMtime = statSync(packageJsonPath).mtimeMs; const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); - // Get image names from docker-compose config - let serverImageName, uiImageName; + // Get project name from docker-compose config, falling back to directory name + let projectName; try { const composeConfig = execSync('docker compose config --format json', { encoding: 'utf-8', cwd: __dirname, }); const config = JSON.parse(composeConfig); - - // Docker Compose generates image names as _ - // Get project name from config or default to directory name - const projectName = - config.name || - path - .basename(__dirname) - .toLowerCase() - .replace(/[^a-z0-9]/g, ''); - serverImageName = `${projectName}_server`; - uiImageName = `${projectName}_ui`; + projectName = config.name; } catch (error) { - // Fallback to default naming convention - const projectName = path - .basename(__dirname) - .toLowerCase() - .replace(/[^a-z0-9]/g, ''); - serverImageName = `${projectName}_server`; - uiImageName = `${projectName}_ui`; + // Fallback handled below } + // Sanitize project name (whether from config or fallback) + // This prevents command injection and ensures valid Docker image names + const sanitizedProjectName = sanitizeProjectName( + projectName || path.basename(__dirname) + ); + const serverImageName = `${sanitizedProjectName}_server`; + const uiImageName = `${sanitizedProjectName}_ui`; + // Check if images exist and get their creation times let needsRebuild = false; From bd5176165df31087ba2daa90877de02cded01a68 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 5 Jan 2026 21:38:18 -0500 Subject: [PATCH 4/5] refactor: remove duplicate logger initialization in useCliStatus hook - Eliminated redundant logger declaration within the useCliStatus hook to improve code clarity and prevent potential performance issues. - This change enhances the maintainability of the code by ensuring the logger is created only once outside the hook. --- apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts index 6782c30b..f543f34f 100644 --- a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts +++ b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts @@ -7,7 +7,6 @@ interface UseCliStatusOptions { setCliStatus: (status: any) => void; setAuthStatus: (status: any) => void; } -const logger = createLogger('CliStatus'); // Create logger once outside the hook to prevent infinite re-renders const logger = createLogger('CliStatus'); From 84b582ffa74914f7c366148a60f4d9ce952281a1 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 5 Jan 2026 21:50:12 -0500 Subject: [PATCH 5/5] refactor: streamline Docker container management and enhance utility functions - Removed redundant Docker image rebuilding logic from `dev.mjs` and `start.mjs`, centralizing it in the new `launchDockerContainers` function within `launcher-utils.mjs`. - Introduced `sanitizeProjectName` and `shouldRebuildDockerImages` functions to improve project name handling and Docker image management. - Updated the Docker launch process to provide clearer logging and ensure proper handling of environment variables, enhancing the overall development experience. --- dev.mjs | 142 +++---------------------------------- scripts/launcher-utils.mjs | 141 +++++++++++++++++++++++++++++++++++- start.mjs | 133 +--------------------------------- 3 files changed, 150 insertions(+), 266 deletions(-) diff --git a/dev.mjs b/dev.mjs index f22a68e4..7c232ca6 100644 --- a/dev.mjs +++ b/dev.mjs @@ -11,15 +11,13 @@ import path from 'path'; import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; -import { statSync } from 'fs'; -import { execSync } from 'child_process'; import { createRestrictedFs, log, runNpm, runNpmAndWait, + runNpx, printHeader, printModeMenu, resolvePortConfiguration, @@ -28,11 +26,9 @@ import { startServerAndWait, ensureDependencies, prompt, + launchDockerContainers, } from './scripts/launcher-utils.mjs'; -const require = createRequire(import.meta.url); -const crossSpawn = require('cross-spawn'); - const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -47,89 +43,6 @@ const processes = { docker: null, }; -/** - * Sanitize a project name to be safe for use in shell commands and Docker image names. - * Converts to lowercase and removes any characters that aren't alphanumeric. - */ -function sanitizeProjectName(name) { - return name.toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -/** - * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes - */ -function shouldRebuildDockerImages() { - try { - const dockerfilePath = path.join(__dirname, 'Dockerfile'); - const packageJsonPath = path.join(__dirname, 'package.json'); - - // Get modification times of source files - const dockerfileMtime = statSync(dockerfilePath).mtimeMs; - const packageJsonMtime = statSync(packageJsonPath).mtimeMs; - const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); - - // Get project name from docker-compose config, falling back to directory name - let projectName; - try { - const composeConfig = execSync('docker compose config --format json', { - encoding: 'utf-8', - cwd: __dirname, - }); - const config = JSON.parse(composeConfig); - projectName = config.name; - } catch (error) { - // Fallback handled below - } - - // Sanitize project name (whether from config or fallback) - // This prevents command injection and ensures valid Docker image names - const sanitizedProjectName = sanitizeProjectName( - projectName || path.basename(__dirname) - ); - const serverImageName = `${sanitizedProjectName}_server`; - const uiImageName = `${sanitizedProjectName}_ui`; - - // Check if images exist and get their creation times - let needsRebuild = false; - - try { - // Check server image - const serverImageInfo = execSync( - `docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, - { encoding: 'utf-8', cwd: __dirname } - ).trim(); - - // Check UI image - const uiImageInfo = execSync( - `docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, - { encoding: 'utf-8', cwd: __dirname } - ).trim(); - - // If either image doesn't exist, we need to rebuild - if (!serverImageInfo || !uiImageInfo) { - return true; - } - - // Parse image creation times (ISO 8601 format) - const serverCreated = new Date(serverImageInfo).getTime(); - const uiCreated = new Date(uiImageInfo).getTime(); - const oldestImageTime = Math.min(serverCreated, uiCreated); - - // If source files are newer than images, rebuild - needsRebuild = latestSourceMtime > oldestImageTime; - } catch (error) { - // If images don't exist or inspect fails, rebuild - needsRebuild = true; - } - - return needsRebuild; - } catch (error) { - // If we can't check, err on the side of rebuilding - log('Could not check Docker image status, will rebuild to be safe', 'yellow'); - return true; - } -} - /** * Install Playwright browsers (dev-only dependency) */ @@ -137,10 +50,11 @@ async function installPlaywrightBrowsers() { log('Checking Playwright browsers...', 'yellow'); try { const exitCode = await new Promise((resolve) => { - const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - }); + const playwright = runNpx( + ['playwright', 'install', 'chromium'], + { stdio: 'inherit' }, + path.join(__dirname, 'apps', 'ui') + ); playwright.on('close', (code) => resolve(code)); playwright.on('error', () => resolve(1)); }); @@ -256,47 +170,7 @@ async function main() { break; } else if (choice === '3') { console.log(''); - log('Launching Docker Container (Isolated Mode)...', 'blue'); - - // Check if Dockerfile or package.json changed and rebuild if needed - const needsRebuild = shouldRebuildDockerImages(); - const buildFlag = needsRebuild ? ['--build'] : []; - - if (needsRebuild) { - log('Dockerfile or package.json changed - rebuilding images...', 'yellow'); - } else { - log('Starting Docker containers...', 'yellow'); - } - console.log(''); - - // Check if ANTHROPIC_API_KEY is set - if (!process.env.ANTHROPIC_API_KEY) { - log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); - log('The server will require an API key to function.', 'yellow'); - log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); - console.log(''); - } - - // Start containers with docker-compose - // Will rebuild if Dockerfile or package.json changed - processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], { - stdio: 'inherit', - cwd: __dirname, - env: { - ...process.env, - }, - }); - - log('Docker containers starting...', 'blue'); - log('UI will be available at: http://localhost:3007', 'green'); - log('API will be available at: http://localhost:3008', 'green'); - console.log(''); - log('Press Ctrl+C to stop the containers.', 'yellow'); - - await new Promise((resolve) => { - processes.docker.on('close', resolve); - }); - + await launchDockerContainers({ baseDir: __dirname, processes }); break; } else { log('Invalid choice. Please enter 1, 2, or 3.', 'red'); diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index 215c0dc2..a1436569 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -13,7 +13,7 @@ */ import { execSync } from 'child_process'; -import fsNative from 'fs'; +import fsNative, { statSync } from 'fs'; import http from 'http'; import path from 'path'; import readline from 'readline'; @@ -662,3 +662,142 @@ export async function ensureDependencies(fs, baseDir) { }); } } + +// ============================================================================= +// Docker Utilities +// ============================================================================= + +/** + * Sanitize a project name to be safe for use in shell commands and Docker image names. + * Converts to lowercase and removes any characters that aren't alphanumeric. + * @param {string} name - Project name to sanitize + * @returns {string} - Sanitized project name + */ +export function sanitizeProjectName(name) { + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** + * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes + * @param {string} baseDir - Base directory containing Dockerfile and package.json + * @returns {boolean} - Whether images need to be rebuilt + */ +export function shouldRebuildDockerImages(baseDir) { + try { + const dockerfilePath = path.join(baseDir, 'Dockerfile'); + const packageJsonPath = path.join(baseDir, 'package.json'); + + // Get modification times of source files + const dockerfileMtime = statSync(dockerfilePath).mtimeMs; + const packageJsonMtime = statSync(packageJsonPath).mtimeMs; + const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); + + // Get project name from docker-compose config, falling back to directory name + let projectName; + try { + const composeConfig = execSync('docker compose config --format json', { + encoding: 'utf-8', + cwd: baseDir, + }); + const config = JSON.parse(composeConfig); + projectName = config.name; + } catch (error) { + // Fallback handled below + } + + // Sanitize project name (whether from config or fallback) + // This prevents command injection and ensures valid Docker image names + const sanitizedProjectName = sanitizeProjectName(projectName || path.basename(baseDir)); + const serverImageName = `${sanitizedProjectName}_server`; + const uiImageName = `${sanitizedProjectName}_ui`; + + // Check if images exist and get their creation times + let needsRebuild = false; + + try { + // Check server image + const serverImageInfo = execSync( + `docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, + { encoding: 'utf-8', cwd: baseDir } + ).trim(); + + // Check UI image + const uiImageInfo = execSync( + `docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, + { encoding: 'utf-8', cwd: baseDir } + ).trim(); + + // If either image doesn't exist, we need to rebuild + if (!serverImageInfo || !uiImageInfo) { + return true; + } + + // Parse image creation times (ISO 8601 format) + const serverCreated = new Date(serverImageInfo).getTime(); + const uiCreated = new Date(uiImageInfo).getTime(); + const oldestImageTime = Math.min(serverCreated, uiCreated); + + // If source files are newer than images, rebuild + needsRebuild = latestSourceMtime > oldestImageTime; + } catch (error) { + // If images don't exist or inspect fails, rebuild + needsRebuild = true; + } + + return needsRebuild; + } catch (error) { + // If we can't check, err on the side of rebuilding + log('Could not check Docker image status, will rebuild to be safe', 'yellow'); + return true; + } +} + +/** + * Launch Docker containers with docker-compose + * @param {object} options - Configuration options + * @param {string} options.baseDir - Base directory containing docker-compose.yml + * @param {object} options.processes - Processes object to track docker process + * @returns {Promise} + */ +export async function launchDockerContainers({ baseDir, processes }) { + log('Launching Docker Container (Isolated Mode)...', 'blue'); + + // Check if Dockerfile or package.json changed and rebuild if needed + const needsRebuild = shouldRebuildDockerImages(baseDir); + const buildFlag = needsRebuild ? ['--build'] : []; + + if (needsRebuild) { + log('Dockerfile or package.json changed - rebuilding images...', 'yellow'); + } else { + log('Starting Docker containers...', 'yellow'); + } + console.log(''); + + // Check if ANTHROPIC_API_KEY is set + if (!process.env.ANTHROPIC_API_KEY) { + log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); + log('The server will require an API key to function.', 'yellow'); + log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); + console.log(''); + } + + // Start containers with docker-compose + // Will rebuild if Dockerfile or package.json changed + processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], { + stdio: 'inherit', + cwd: baseDir, + env: { + ...process.env, + }, + }); + + log('Docker containers starting...', 'blue'); + log('UI will be available at: http://localhost:3007', 'green'); + log('API will be available at: http://localhost:3008', 'green'); + console.log(''); + log('Press Ctrl+C to stop the containers.', 'yellow'); + + await new Promise((resolve) => { + processes.docker.on('close', resolve); + }); +} diff --git a/start.mjs b/start.mjs index 29a6e04e..326bb4b7 100755 --- a/start.mjs +++ b/start.mjs @@ -18,13 +18,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; -import { statSync } from 'fs'; -import { execSync } from 'child_process'; import { createRestrictedFs, log, - runNpm, runNpmAndWait, runNpx, printHeader, @@ -37,11 +33,9 @@ import { prompt, killProcessTree, sleep, + launchDockerContainers, } from './scripts/launcher-utils.mjs'; -const require = createRequire(import.meta.url); -const crossSpawn = require('cross-spawn'); - const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -56,89 +50,6 @@ const processes = { docker: null, }; -/** - * Sanitize a project name to be safe for use in shell commands and Docker image names. - * Converts to lowercase and removes any characters that aren't alphanumeric. - */ -function sanitizeProjectName(name) { - return name.toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -/** - * Check if Docker images need to be rebuilt based on Dockerfile or package.json changes - */ -function shouldRebuildDockerImages() { - try { - const dockerfilePath = path.join(__dirname, 'Dockerfile'); - const packageJsonPath = path.join(__dirname, 'package.json'); - - // Get modification times of source files - const dockerfileMtime = statSync(dockerfilePath).mtimeMs; - const packageJsonMtime = statSync(packageJsonPath).mtimeMs; - const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime); - - // Get project name from docker-compose config, falling back to directory name - let projectName; - try { - const composeConfig = execSync('docker compose config --format json', { - encoding: 'utf-8', - cwd: __dirname, - }); - const config = JSON.parse(composeConfig); - projectName = config.name; - } catch (error) { - // Fallback handled below - } - - // Sanitize project name (whether from config or fallback) - // This prevents command injection and ensures valid Docker image names - const sanitizedProjectName = sanitizeProjectName( - projectName || path.basename(__dirname) - ); - const serverImageName = `${sanitizedProjectName}_server`; - const uiImageName = `${sanitizedProjectName}_ui`; - - // Check if images exist and get their creation times - let needsRebuild = false; - - try { - // Check server image - const serverImageInfo = execSync( - `docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, - { encoding: 'utf-8', cwd: __dirname } - ).trim(); - - // Check UI image - const uiImageInfo = execSync( - `docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`, - { encoding: 'utf-8', cwd: __dirname } - ).trim(); - - // If either image doesn't exist, we need to rebuild - if (!serverImageInfo || !uiImageInfo) { - return true; - } - - // Parse image creation times (ISO 8601 format) - const serverCreated = new Date(serverImageInfo).getTime(); - const uiCreated = new Date(uiImageInfo).getTime(); - const oldestImageTime = Math.min(serverCreated, uiCreated); - - // If source files are newer than images, rebuild - needsRebuild = latestSourceMtime > oldestImageTime; - } catch (error) { - // If images don't exist or inspect fails, rebuild - needsRebuild = true; - } - - return needsRebuild; - } catch (error) { - // If we can't check, err on the side of rebuilding - log('Could not check Docker image status, will rebuild to be safe', 'yellow'); - return true; - } -} - /** * Build all production artifacts */ @@ -315,47 +226,7 @@ async function main() { break; } else if (choice === '3') { console.log(''); - log('Launching Docker Container (Isolated Mode)...', 'blue'); - - // Check if Dockerfile or package.json changed and rebuild if needed - const needsRebuild = shouldRebuildDockerImages(); - const buildFlag = needsRebuild ? ['--build'] : []; - - if (needsRebuild) { - log('Dockerfile or package.json changed - rebuilding images...', 'yellow'); - } else { - log('Starting Docker containers...', 'yellow'); - } - console.log(''); - - // Check if ANTHROPIC_API_KEY is set - if (!process.env.ANTHROPIC_API_KEY) { - log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); - log('The server will require an API key to function.', 'yellow'); - log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); - console.log(''); - } - - // Start containers with docker-compose - // Will rebuild if Dockerfile or package.json changed - processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], { - stdio: 'inherit', - cwd: __dirname, - env: { - ...process.env, - }, - }); - - log('Docker containers starting...', 'blue'); - log('UI will be available at: http://localhost:3007', 'green'); - log('API will be available at: http://localhost:3008', 'green'); - console.log(''); - log('Press Ctrl+C to stop the containers.', 'yellow'); - - await new Promise((resolve) => { - processes.docker.on('close', resolve); - }); - + await launchDockerContainers({ baseDir: __dirname, processes }); break; } else { log('Invalid choice. Please enter 1, 2, or 3.', 'red');