|
#!/usr/bin/env bash |
|
|
|
# cnme - Continue CLI containerized for ME |
|
# Safe containerized wrapper for Continue CLI that provides complete isolation |
|
# while maintaining full development functionality |
|
|
|
# Bash strict mode for better error handling |
|
set -euo pipefail |
|
# IFS (Internal Field Separator) - controls how bash splits strings |
|
# Setting to newline and tab prevents issues with spaces in filenames/paths |
|
IFS=$'\n\t' |
|
|
|
# Configuration - using readonly for constants to prevent accidental changes |
|
readonly CONTAINER_IMAGE="cnme:latest" |
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|
|
|
# Generate unique container name to avoid conflicts |
|
readonly CONTAINER_NAME="cnme-$$-$(date +%s)" |
|
|
|
# Cleanup function - called on script exit to ensure containers are cleaned up |
|
cleanup() { |
|
local exit_code=$? |
|
if [[ -n "${CONTAINER_NAME:-}" ]]; then |
|
# Kill container if it's still running (suppress errors since it might not exist) |
|
docker kill "$CONTAINER_NAME" 2>/dev/null || true |
|
fi |
|
exit $exit_code |
|
} |
|
|
|
# Set cleanup to run on script exit (normal or error) |
|
trap cleanup EXIT |
|
|
|
# Colors for output - readonly to prevent accidental modification |
|
readonly RED='\033[0;31m' |
|
readonly GREEN='\033[0;32m' |
|
readonly YELLOW='\033[1;33m' |
|
readonly BLUE='\033[0;34m' |
|
readonly NC='\033[0m' # No Color |
|
|
|
# Logging functions - all output to stderr to avoid interfering with command output |
|
log() { |
|
# Use printf instead of echo -e for better portability |
|
printf '%b[cnme]%b %s\n' "$BLUE" "$NC" "$1" >&2 |
|
} |
|
|
|
warn() { |
|
printf '%b[cnme] WARNING:%b %s\n' "$YELLOW" "$NC" "$1" >&2 |
|
} |
|
|
|
error() { |
|
printf '%b[cnme] ERROR:%b %s\n' "$RED" "$NC" "$1" >&2 |
|
exit 1 |
|
} |
|
|
|
success() { |
|
printf '%b[cnme]%b %s\n' "$GREEN" "$NC" "$1" >&2 |
|
} |
|
|
|
# Check if we're already inside a container |
|
is_containerized() { |
|
[[ "${CNME_CONTAINERIZED:-}" == "true" ]] |
|
} |
|
|
|
# Check if this is headless mode (non-interactive) |
|
is_headless_mode() { |
|
local args=("$@") |
|
for arg in "${args[@]}"; do |
|
case "$arg" in |
|
-p|--print) |
|
return 0 |
|
;; |
|
esac |
|
done |
|
# Also check for --silent or --format which indicate headless output formatting |
|
for arg in "${args[@]}"; do |
|
case "$arg" in |
|
--silent|--format) |
|
return 0 |
|
;; |
|
esac |
|
done |
|
return 1 |
|
} |
|
|
|
# Check if Docker is available and running |
|
check_docker() { |
|
# Check if docker command exists |
|
if ! command -v docker >/dev/null 2>&1; then |
|
case "$OSTYPE" in |
|
darwin*) |
|
error "Docker not found. Install with: brew install docker colima && colima start" |
|
;; |
|
linux-gnu*) |
|
error "Docker not found. Install with: apt-get install docker.io (or equivalent)" |
|
;; |
|
*) |
|
error "Docker not found. Please install Docker for your system." |
|
;; |
|
esac |
|
fi |
|
|
|
# Check if docker daemon is running - capture both stdout and stderr |
|
if ! docker ps >/dev/null 2>&1; then |
|
# More specific error message based on common failure modes |
|
if docker version --format '{{.Client.Version}}' >/dev/null 2>&1; then |
|
error "Docker daemon not running. Please start Docker." |
|
else |
|
error "Docker not accessible. Check installation and permissions." |
|
fi |
|
fi |
|
} |
|
|
|
# Build container image if it doesn't exist |
|
ensure_image() { |
|
if docker image inspect "$CONTAINER_IMAGE" >/dev/null 2>&1; then |
|
log "Using existing container image: $CONTAINER_IMAGE" |
|
return |
|
fi |
|
|
|
log "Building container image: $CONTAINER_IMAGE" |
|
log "This may take a few minutes the first time..." |
|
|
|
# Check if Dockerfile exists - use SCRIPT_DIR for more robust path handling |
|
local dockerfile_path="$SCRIPT_DIR/Dockerfile" |
|
if [[ ! -f "$dockerfile_path" ]]; then |
|
error "Dockerfile not found at: $dockerfile_path" |
|
fi |
|
|
|
# Get current user's UID, GID, and username for matching inside container |
|
local host_uid host_gid host_user |
|
host_uid=$(id -u) |
|
host_gid=$(id -g) |
|
host_user=$(whoami) |
|
|
|
log "Building with host user: $host_user ($host_uid:$host_gid)" |
|
|
|
# Build the image with host user info as build args |
|
# Use SCRIPT_DIR for build context and add --quiet for cleaner output |
|
if ! docker build \ |
|
--build-arg HOST_UID="$host_uid" \ |
|
--build-arg HOST_GID="$host_gid" \ |
|
--build-arg HOST_USER="$host_user" \ |
|
--tag "$CONTAINER_IMAGE" \ |
|
--file "$dockerfile_path" \ |
|
"$SCRIPT_DIR"; then |
|
error "Failed to build container image. Check Docker daemon and Dockerfile." |
|
fi |
|
|
|
success "Container image built successfully" |
|
} |
|
|
|
# Get mount configurations with validation |
|
get_mounts() { |
|
local quiet=${1:-false} |
|
local host_user=${2:-$(whoami)} |
|
local mounts=() |
|
|
|
# Validate inputs |
|
if [[ -z "$host_user" ]]; then |
|
error "Host user not specified for mount configuration" |
|
fi |
|
|
|
# Always mount current directory as workspace - ensure PWD is set |
|
if [[ -z "${PWD:-}" ]]; then |
|
error "Current directory (PWD) not available for mounting" |
|
fi |
|
mounts+=("-v" "$PWD:/workspace") |
|
if [[ "$quiet" != "true" ]]; then |
|
log "Mounting workspace: $PWD (rw)" |
|
fi |
|
|
|
# Mount Continue config if it exists (read-write for authentication/sessions) |
|
if [[ -d "$HOME/.continue" ]]; then |
|
mounts+=("-v" "$HOME/.continue:/home/${host_user}/.continue:rw") |
|
if [[ "$quiet" != "true" ]]; then |
|
log "Mounting Continue config: $HOME/.continue (rw)" |
|
fi |
|
else |
|
if [[ "$quiet" != "true" ]]; then |
|
warn "Continue config directory not found: $HOME/.continue" |
|
fi |
|
fi |
|
|
|
# Optional: Mount SSH keys for git operations (read-only) |
|
if [[ -d "$HOME/.ssh" ]]; then |
|
mounts+=("-v" "$HOME/.ssh:/home/${host_user}/.ssh:ro") |
|
if [[ "$quiet" != "true" ]]; then |
|
log "Mounting SSH keys: $HOME/.ssh (ro)" |
|
fi |
|
fi |
|
|
|
# Optional: Mount git config (read-only) |
|
if [[ -f "$HOME/.gitconfig" ]]; then |
|
mounts+=("-v" "$HOME/.gitconfig:/home/${host_user}/.gitconfig:ro") |
|
if [[ "$quiet" != "true" ]]; then |
|
log "Mounting git config: $HOME/.gitconfig (ro)" |
|
fi |
|
fi |
|
|
|
# Output all mounts - one per line for easier parsing |
|
printf "%s\n" "${mounts[@]}" |
|
} |
|
|
|
# Run Continue CLI in container |
|
run_containerized() { |
|
check_docker |
|
if [[ "$silent_mode" != "true" ]]; then |
|
ensure_image |
|
else |
|
# Silent mode - ensure image exists but don't show output |
|
ensure_image >/dev/null 2>&1 |
|
fi |
|
|
|
# Only show verbose output if not in headless/silent mode |
|
if ! is_headless_mode "$@" && [[ "$silent_mode" != "true" ]]; then |
|
log "Starting Continue CLI in secure container..." |
|
log "Project directory: $PWD β /workspace" |
|
fi |
|
|
|
# Prepare docker run arguments - using array for safer argument handling |
|
local docker_args=( |
|
"run" |
|
"--rm" # Remove container when it exits |
|
"--interactive" # Keep stdin open |
|
"--name" "$CONTAINER_NAME" |
|
) |
|
|
|
# Add TTY if we have one (check if stdin is a terminal) |
|
if [[ -t 0 ]]; then |
|
docker_args+=("--tty") |
|
fi |
|
|
|
# Get host user info for dynamic configuration |
|
local host_user |
|
host_user=$(whoami) |
|
|
|
# Add all mounts (quiet mode for headless or silent) |
|
local mounts |
|
local quiet_mounts="false" |
|
if is_headless_mode "$@" || [[ "$silent_mode" == "true" ]]; then |
|
quiet_mounts="true" |
|
fi |
|
readarray -t mounts < <(get_mounts "$quiet_mounts" "$host_user") |
|
docker_args+=("${mounts[@]}") |
|
|
|
# Security settings |
|
docker_args+=( |
|
"--user" "$host_user" # Host-matching user |
|
"--network" "bridge" # Network access needed for Continue auth/API |
|
"--memory" "2g" # Memory limit |
|
"--cpus" "2.0" # CPU limit |
|
"--security-opt" "no-new-privileges" # Prevent privilege escalation |
|
) |
|
|
|
# Environment variables |
|
docker_args+=( |
|
"-e" "CNME_CONTAINERIZED=true" # Mark as containerized |
|
"-e" "HOME=/home/$host_user" # Set proper home directory |
|
"-e" "USER=$host_user" # Set user |
|
) |
|
|
|
# Add the image and command |
|
docker_args+=("$CONTAINER_IMAGE" "cn") |
|
|
|
# Add all original arguments |
|
docker_args+=("$@") |
|
|
|
# Execute the container - exec replaces current process |
|
# Cleanup is handled by the global trap set at script start |
|
exec docker "${docker_args[@]}" |
|
} |
|
|
|
# Show usage information |
|
show_usage() { |
|
echo "cnme - Continue CLI containerized for ME" |
|
echo |
|
echo "TRANSPARENT CONTAINERIZED WRAPPER:" |
|
echo " All Continue CLI commands and options work exactly the same." |
|
echo " cnme adds complete safety through Docker container isolation." |
|
echo |
|
echo "EXAMPLES:" |
|
echo " cnme # Interactive mode" |
|
echo " cnme -p \"Fix this bug\" # Headless mode" |
|
echo " cnme --format json -p \"Test\" # JSON output" |
|
echo " cnme --readonly # Read-only mode" |
|
echo " cnme login # Authentication" |
|
echo " cnme serve # HTTP server mode" |
|
echo |
|
echo "CNME-SPECIFIC OPTIONS:" |
|
echo " --unsafe Bypass container (run cn directly - not recommended)" |
|
echo " --rebuild-image Force rebuild of container image" |
|
echo " --shell Drop into container shell for debugging" |
|
echo |
|
echo "SECURITY FEATURES:" |
|
echo " β
Complete isolation from host system" |
|
echo " β
All dangerous commands contained in disposable container" |
|
echo " β
Host user matching for proper file permissions" |
|
echo " β
Memory/CPU limits and controlled network access" |
|
echo " β
No privilege escalation allowed" |
|
echo |
|
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" |
|
} |
|
|
|
# Main execution logic |
|
main() { |
|
# If already containerized, this shouldn't happen with our design |
|
# but just in case, pass through to real cn |
|
if is_containerized; then |
|
exec cn "$@" |
|
fi |
|
|
|
# Parse arguments once to detect silent mode |
|
local silent_mode=false |
|
for arg in "$@"; do |
|
case "$arg" in |
|
--silent) |
|
silent_mode=true |
|
break |
|
;; |
|
esac |
|
done |
|
|
|
# Handle cnme-specific flags first, before passing to Continue CLI |
|
local cnme_args=() |
|
local cn_args=() |
|
local skip_next=false |
|
|
|
for arg in "$@"; do |
|
if [[ "$skip_next" == "true" ]]; then |
|
skip_next=false |
|
continue |
|
fi |
|
|
|
case "$arg" in |
|
--help|-h) |
|
# Show both cnme and cn help |
|
show_usage |
|
echo |
|
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" |
|
echo "Continue CLI (cn) Help:" |
|
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" |
|
run_containerized --help |
|
exit 0 |
|
;; |
|
--unsafe) |
|
warn "Running Continue CLI in UNSAFE mode (no container protection)" |
|
# Remove this argument and run cn directly with remaining args |
|
shift |
|
# Try to find cn binary |
|
if command -v cn >/dev/null 2>&1; then |
|
exec cn "$@" |
|
else |
|
error "Continue CLI (cn) not found. Install with: npm install -g @continuedev/cli" |
|
fi |
|
;; |
|
--rebuild-image) |
|
log "Removing existing container image..." |
|
docker rmi "$CONTAINER_IMAGE" 2>/dev/null || true |
|
# Remove this arg and continue with remaining args |
|
cnme_args+=("$arg") |
|
;; |
|
--shell) |
|
check_docker |
|
ensure_image |
|
|
|
log "Opening debug shell in container..." |
|
local host_user mounts |
|
host_user=$(whoami) |
|
readarray -t mounts < <(get_mounts "false" "$host_user") |
|
|
|
exec docker run -it --rm \ |
|
"${mounts[@]}" \ |
|
--user "$host_user" \ |
|
--network bridge \ |
|
--memory 2g \ |
|
--cpus 2.0 \ |
|
--security-opt no-new-privileges \ |
|
-e "CNME_CONTAINERIZED=true" \ |
|
-e "HOME=/home/$host_user" \ |
|
-e "USER=$host_user" \ |
|
"$CONTAINER_IMAGE" bash |
|
;; |
|
*) |
|
# All other arguments go to Continue CLI |
|
cn_args+=("$arg") |
|
;; |
|
esac |
|
done |
|
|
|
# Remove cnme-specific args from the original argument list |
|
set -- "${cn_args[@]}" |
|
|
|
# Run in containerized mode with remaining args |
|
run_containerized "$@" |
|
} |
|
|
|
# Execute main function with all arguments |
|
main "$@" |