Last active
June 19, 2026 18:46
-
-
Save agrublev/2190bb8a3fd9bb57ce8f327413f4f432 to your computer and use it in GitHub Desktop.
cloud-init-gist.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # cloud-init-gist.sh — self-contained first-boot bootstrap for a DigitalOcean | |
| # Droplet that builds and runs the trimmed claude-universal image (Claude Code + | |
| # Python/Node/Java). Paste this whole file into a PUBLIC GitHub gist, then set the | |
| # droplet's user-data to a 2-line bootstrap that curls + runs it (see README at | |
| # the bottom of this file). | |
| # | |
| # Design: | |
| # - Embeds every build file inline (no git, no registry, no rsync needed). | |
| # - Builds the image from source ON the droplet (needs 8GB RAM + swap). | |
| # - Runs the container DETACHED and keeps it up (restart: unless-stopped) so you | |
| # can `docker exec` into it anytime via the `claude-shell` launcher. | |
| # - Contains NO secrets. You sign into Claude Code interactively after boot; | |
| # the auth persists in the `claude-config` Docker volume. | |
| # - Idempotent: re-running is a no-op once /root/.claude-setup-complete exists. | |
| # | |
| # SECURITY: never put ANTHROPIC_API_KEY (or any secret) in this gist — it is | |
| # world-readable. Sign in interactively with `claude` after the droplet is up. | |
| set -euo pipefail | |
| REMOTE_DIR="/root/claude-universal" | |
| MARKER="/root/.claude-setup-complete" | |
| SWAP_GB="${SWAP_GB:-8}" | |
| log() { echo ">>> [claude-setup] $*"; } | |
| # --- Idempotency guard ------------------------------------------------------- | |
| if [ -f "$MARKER" ]; then | |
| log "Already set up (found $MARKER). Nothing to do." | |
| exit 0 | |
| fi | |
| export DEBIAN_FRONTEND=noninteractive | |
| # --- Patch the host OS ------------------------------------------------------- | |
| # Clears DigitalOcean's "update and reboot" notice. full-upgrade (not plain | |
| # upgrade) so held-back kernel/lib updates actually install. No reboot here: | |
| # this is first boot, and the container does not depend on the new kernel. | |
| log "Patching host OS (apt-get update && full-upgrade)..." | |
| apt-get -o DPkg::Lock::Timeout=300 update | |
| apt-get -y -o DPkg::Lock::Timeout=300 -o Dpkg::Options::=--force-confold full-upgrade | |
| apt-get -y -o DPkg::Lock::Timeout=300 autoremove --purge | |
| apt-get clean | |
| /usr/lib/update-notifier/update-motd-updates-available --force >/dev/null 2>&1 || true | |
| # --- Ensure swap ------------------------------------------------------------- | |
| # DO droplets ship swapless; the from-source compiles can spike past RAM and get | |
| # OOM-killed, silently aborting the build. Add swap as a guard. | |
| if [ "$SWAP_GB" != "0" ] && ! swapon --show | grep -q /swapfile; then | |
| log "Adding ${SWAP_GB}G swap..." | |
| fallocate -l "${SWAP_GB}G" /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=$((SWAP_GB * 1024)) | |
| chmod 600 /swapfile | |
| mkswap /swapfile | |
| swapon /swapfile | |
| grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab | |
| fi | |
| # --- Ensure Docker + Compose ------------------------------------------------- | |
| if ! command -v docker >/dev/null 2>&1; then | |
| log "Installing Docker..." | |
| apt-get -o DPkg::Lock::Timeout=300 install -y docker.io | |
| fi | |
| if ! docker compose version >/dev/null 2>&1; then | |
| log "Installing docker compose plugin..." | |
| apt-get -o DPkg::Lock::Timeout=300 install -y docker-compose-plugin | |
| fi | |
| systemctl enable --now docker >/dev/null 2>&1 || true | |
| # --- Write the build context ------------------------------------------------- | |
| log "Writing build files to ${REMOTE_DIR}..." | |
| mkdir -p "${REMOTE_DIR}/opt" | |
| cd "${REMOTE_DIR}" | |
| cat > Dockerfile <<'DOCKERFILE_EOF' | |
| # syntax=docker/dockerfile:1.7 | |
| FROM ubuntu:24.04 | |
| ARG TARGETOS | |
| ARG TARGETARCH | |
| ENV LANG="C.UTF-8" | |
| ENV HOME=/root | |
| ENV DEBIAN_FRONTEND=noninteractive | |
| # Cap compiler parallelism during the many from-source builds (CPython, PHP, | |
| # Erlang, Rust). Limiting -j keeps peak RAM bounded so the build survives on | |
| # smaller / swap-light hosts. Override with --build-arg MAKEFLAGS=-jN. | |
| ARG MAKEFLAGS="-j2" | |
| ENV MAKEFLAGS="${MAKEFLAGS}" | |
| ENV CARGO_BUILD_JOBS=2 | |
| ### BASE ### | |
| # Apply outstanding security updates from the base image first so the built | |
| # image doesn't ship packages flagged as out-of-date / vulnerable. Pinned | |
| # package versions below may be bumped by this upgrade; that is intended. | |
| RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| --mount=type=cache,target=/var/lib/apt,sharing=locked \ | |
| apt-get update \ | |
| && apt-get -y --no-install-recommends upgrade \ | |
| && rm -rf /var/lib/apt/lists/* | |
| RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| --mount=type=cache,target=/var/lib/apt,sharing=locked \ | |
| apt-get update \ | |
| && apt-get install -y --no-install-recommends \ | |
| binutils=2.42-* \ | |
| sudo=1.9.* \ | |
| build-essential=12.10* \ | |
| curl=8.5.* \ | |
| dnsutils=1:9.18.* \ | |
| fd-find=9.0.* \ | |
| gettext=0.21-* \ | |
| git=1:2.43.* \ | |
| git-lfs=3.4.* \ | |
| gnupg=2.4.* \ | |
| inotify-tools=3.22.* \ | |
| iputils-ping=3:20240117-* \ | |
| jq=1.7.* \ | |
| libbz2-dev=1.0.* \ | |
| libc6=2.39-* \ | |
| libc6-dev=2.39-* \ | |
| libcurl4-openssl-dev=8.5.* \ | |
| libffi-dev=3.4.* \ | |
| libgdbm-compat-dev=1.23-* \ | |
| libgdbm-dev=1.23-* \ | |
| liblzma-dev=5.6.* \ | |
| libncurses-dev=6.4+20240113-* \ | |
| libreadline-dev=8.2-* \ | |
| libsqlite3-dev=3.45.* \ | |
| libssl-dev=3.0.* \ | |
| make=4.3-* \ | |
| moreutils=0.69-* \ | |
| netcat-openbsd=1.226-* \ | |
| openssh-client=1:9.6p1-* \ | |
| pkg-config=1.8.* \ | |
| ripgrep=14.1.* \ | |
| rsync=3.2.* \ | |
| software-properties-common=0.99.* \ | |
| sqlite3=3.45.* \ | |
| tk-dev=8.6.* \ | |
| tzdata \ | |
| universal-ctags=5.9.* \ | |
| unzip=6.0-* \ | |
| uuid-dev=2.39.* \ | |
| wget=1.21.* \ | |
| xz-utils=5.6.* \ | |
| zip=3.0-* \ | |
| zlib1g=1:1.3.* \ | |
| zlib1g-dev=1:1.3.* \ | |
| && rm -rf /var/lib/apt/lists/* | |
| ### MISE ### | |
| RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| --mount=type=cache,target=/var/lib/apt,sharing=locked \ | |
| install -dm 0755 /etc/apt/keyrings \ | |
| && curl -fsSL https://mise.jdx.dev/gpg-key.pub | gpg --batch --yes --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \ | |
| && chmod 0644 /etc/apt/keyrings/mise-archive-keyring.gpg \ | |
| && echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg] https://mise.jdx.dev/deb stable main" > /etc/apt/sources.list.d/mise.list \ | |
| && apt-get update \ | |
| && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mise/stable \ | |
| && rm -rf /var/lib/apt/lists/* \ | |
| && echo 'eval "$(mise activate bash)"' >> /etc/profile \ | |
| && mise settings set experimental true \ | |
| && mise settings set override_tool_versions_filenames none \ | |
| && mise settings add idiomatic_version_file_enable_tools "[]" \ | |
| && mise settings add disable_backends asdf \ | |
| && mise settings add disable_backends vfox | |
| ENV PATH=$HOME/.local/share/mise/shims:$PATH | |
| ### PYTHON ### | |
| # Trimmed to a single current Python. Add more here if a project needs them. | |
| ARG PYTHON_VERSIONS="3.13" | |
| # Install pyenv | |
| ENV PYENV_ROOT=/root/.pyenv | |
| ENV PATH=$PYENV_ROOT/bin:$PATH | |
| RUN git -c advice.detachedHead=0 clone --depth 1 https://github.com/pyenv/pyenv.git "$PYENV_ROOT" \ | |
| && echo 'export PYENV_ROOT="$HOME/.pyenv"' >> /etc/profile \ | |
| && echo 'export PATH="$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH"' >> /etc/profile \ | |
| && echo 'eval "$(pyenv init - bash)"' >> /etc/profile \ | |
| && cd "$PYENV_ROOT" \ | |
| && src/configure \ | |
| && make -C src \ | |
| && pyenv install $PYTHON_VERSIONS \ | |
| && rm -rf "$PYENV_ROOT/cache" | |
| # Install pipx for common global package managers (e.g. poetry) | |
| ENV PIPX_BIN_DIR=/root/.local/bin | |
| ENV PATH=$PIPX_BIN_DIR:$PATH | |
| RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| --mount=type=cache,target=/var/lib/apt,sharing=locked \ | |
| --mount=type=cache,target=/root/.cache/pip \ | |
| --mount=type=cache,target=/root/.cache/pipx \ | |
| apt-get update \ | |
| && apt-get install -y --no-install-recommends pipx=1.4.* \ | |
| && rm -rf /var/lib/apt/lists/* \ | |
| && pipx install --pip-args="--no-cache-dir --no-compile --root-user-action=ignore" poetry==2.1.* uv==0.7.* \ | |
| && for pyv in "${PYENV_ROOT}/versions/"*; do \ | |
| "$pyv/bin/python" -m pip install --no-cache-dir --no-compile --root-user-action=ignore --upgrade pip && \ | |
| "$pyv/bin/pip" install --no-cache-dir --no-compile --root-user-action=ignore ruff black mypy pyright isort pytest; \ | |
| done | |
| # Reduce the verbosity of uv - impacts performance of stdout buffering | |
| ENV UV_NO_PROGRESS=1 | |
| ### NODE ### | |
| ARG NVM_VERSION=v0.40.2 | |
| ARG NODE_VERSION=22 | |
| ENV NVM_DIR=/root/.nvm | |
| # Corepack tries to do too much - disable some of its features: | |
| # https://github.com/nodejs/corepack/blob/main/README.md | |
| ENV COREPACK_DEFAULT_TO_LATEST=0 | |
| ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 | |
| ENV COREPACK_ENABLE_AUTO_PIN=0 | |
| ENV COREPACK_ENABLE_STRICT=0 | |
| RUN --mount=type=cache,target=/root/.npm \ | |
| --mount=type=cache,target=/root/.cache/yarn \ | |
| --mount=type=cache,target=/root/.local/share/pnpm/store \ | |
| git -c advice.detachedHead=0 clone --branch "$NVM_VERSION" --depth 1 https://github.com/nvm-sh/nvm.git "$NVM_DIR" \ | |
| && echo 'source $NVM_DIR/nvm.sh' >> /etc/profile \ | |
| && echo "prettier\neslint\ntypescript" > $NVM_DIR/default-packages \ | |
| && . $NVM_DIR/nvm.sh \ | |
| # Trimmed to a single Node version (the default). Add more `nvm install` lines here if needed. | |
| && nvm install "$NODE_VERSION" && nvm use "$NODE_VERSION" && npm install -g npm@11.4 pnpm@10.12 && corepack enable && corepack install -g yarn \ | |
| && nvm alias default "$NODE_VERSION" \ | |
| && nvm cache clear \ | |
| && npm cache clean --force || true \ | |
| && pnpm store prune || true \ | |
| && yarn cache clean || true | |
| ### CLAUDE CODE ### | |
| # Install Claude Code via the native installer (Anthropic's recommended method). | |
| # It self-installs to /root/.local/bin/claude (already on PATH above) and does | |
| # not depend on any particular nvm/Node version being active, so there's no | |
| # fragile symlink to maintain. Symlink into /usr/local/bin anyway so `claude` | |
| # resolves even in shells that haven't sourced the profile. | |
| RUN curl -fsSL https://claude.ai/install.sh | bash \ | |
| && ln -sf /root/.local/bin/claude /usr/local/bin/claude | |
| ### JAVA ### | |
| ARG GRADLE_VERSION=8.14 | |
| ARG MAVEN_VERSION=3.9.10 | |
| # Trimmed to a single current LTS-era JDK to keep the image small and the build | |
| # fast. Add older versions back here if a project needs them. | |
| ARG AMD_JAVA_VERSIONS="25" | |
| ARG ARM_JAVA_VERSIONS="25" | |
| RUN --mount=type=cache,target=/root/.cache/mise \ | |
| JAVA_VERSIONS="$( [ "$TARGETARCH" = "arm64" ] && echo "$ARM_JAVA_VERSIONS" || echo "$AMD_JAVA_VERSIONS" )" \ | |
| && for v in $JAVA_VERSIONS; do mise install "java@${v}"; done \ | |
| && mise use --global "java@${JAVA_VERSIONS%% *}" \ | |
| && mise use --global "gradle@${GRADLE_VERSION}" \ | |
| && mise use --global "maven@${MAVEN_VERSION}" \ | |
| && mise cache clear || true | |
| ### SHELL (zsh + oh-my-zsh + nano) ### | |
| # Install zsh and nano, then oh-my-zsh unattended (RUNZSH=no keeps the build | |
| # non-interactive, CHSH=no avoids changing root's login shell here — we set it | |
| # explicitly afterwards so interactive `docker run` lands in zsh). | |
| RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| --mount=type=cache,target=/var/lib/apt,sharing=locked \ | |
| apt-get update \ | |
| && apt-get install -y --no-install-recommends \ | |
| zsh=5.9-* \ | |
| nano=7.2-* \ | |
| && rm -rf /var/lib/apt/lists/* \ | |
| && RUNZSH=no CHSH=no KEEP_ZSHRC=yes \ | |
| sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" \ | |
| # Source the existing /etc/profile (mise/nvm/pyenv/etc.) from zsh too, so all | |
| # language toolchains are on PATH in an interactive zsh session. | |
| && echo 'emulate sh -c "source /etc/profile"' >> /root/.zshrc \ | |
| && chsh -s /usr/bin/zsh root | |
| ### SETUP SCRIPTS ### | |
| COPY setup_universal.sh /opt/codex/setup_universal.sh | |
| RUN chmod +x /opt/codex/setup_universal.sh | |
| ### VERIFICATION SCRIPT ### | |
| COPY verify.sh /opt/verify.sh | |
| RUN chmod +x /opt/verify.sh \ | |
| && PYTHON_VERSIONS="$PYTHON_VERSIONS" \ | |
| NODE_VERSIONS="$NODE_VERSION" \ | |
| JAVA_VERSIONS="$( [ "$TARGETARCH" = "arm64" ] && echo "$ARM_JAVA_VERSIONS" || echo "$AMD_JAVA_VERSIONS" )" \ | |
| "/opt/verify.sh" | |
| ### ENTRYPOINT ### | |
| # Clear the build-only compiler parallelism caps so they don't leak into the | |
| # running container — otherwise user-initiated compiles inside the container | |
| # would silently inherit -j2. These only ever needed to bound peak RAM during | |
| # the from-source builds above. | |
| ENV MAKEFLAGS="" | |
| ENV CARGO_BUILD_JOBS="" | |
| COPY entrypoint.sh /opt/entrypoint.sh | |
| RUN chmod +x /opt/entrypoint.sh | |
| ENTRYPOINT ["/opt/entrypoint.sh"] | |
| DOCKERFILE_EOF | |
| cat > entrypoint.sh <<'ENTRYPOINT_EOF' | |
| #!/bin/bash | |
| echo "==========================================" | |
| echo " claude-universal — Claude Code ready" | |
| echo "==========================================" | |
| /opt/codex/setup_universal.sh | |
| echo "" | |
| if command -v claude >/dev/null 2>&1; then | |
| echo "Claude Code: $(claude --version 2>/dev/null || echo 'installed')" | |
| else | |
| echo "WARNING: 'claude' not found on PATH." | |
| fi | |
| if [ -n "${ANTHROPIC_API_KEY:-}" ]; then | |
| echo "Auth: ANTHROPIC_API_KEY detected (Claude Code will use it)." | |
| else | |
| echo "Auth: no ANTHROPIC_API_KEY set. Run 'claude' and sign in interactively," | |
| echo " or pass -e ANTHROPIC_API_KEY=... when starting the container." | |
| fi | |
| echo "" | |
| # Auto-launch Claude Code if requested (e.g. CLAUDE_AUTOSTART=1). | |
| if [ "${CLAUDE_AUTOSTART:-0}" = "1" ] && command -v claude >/dev/null 2>&1; then | |
| echo "CLAUDE_AUTOSTART=1 — launching Claude Code..." | |
| exec claude "$@" | |
| fi | |
| echo "Environment ready. Type 'claude' to start Claude Code. Dropping you into a shell." | |
| # Prefer zsh (oh-my-zsh) for interactive use; fall back to bash if unavailable. | |
| if command -v zsh >/dev/null 2>&1; then | |
| exec zsh --login "$@" | |
| fi | |
| exec bash --login "$@" | |
| ENTRYPOINT_EOF | |
| cat > setup_universal.sh <<'SETUP_EOF' | |
| #!/bin/bash --login | |
| set -euo pipefail | |
| # Accept both CLAUDE_ENV_* (preferred) and the upstream CODEX_ENV_* names for | |
| # backward compatibility. CLAUDE_ENV_* wins if both are set. | |
| CODEX_ENV_PYTHON_VERSION=${CLAUDE_ENV_PYTHON_VERSION:-${CODEX_ENV_PYTHON_VERSION:-}} | |
| CODEX_ENV_NODE_VERSION=${CLAUDE_ENV_NODE_VERSION:-${CODEX_ENV_NODE_VERSION:-}} | |
| CODEX_ENV_RUBY_VERSION=${CLAUDE_ENV_RUBY_VERSION:-${CODEX_ENV_RUBY_VERSION:-}} | |
| CODEX_ENV_RUST_VERSION=${CLAUDE_ENV_RUST_VERSION:-${CODEX_ENV_RUST_VERSION:-}} | |
| CODEX_ENV_GO_VERSION=${CLAUDE_ENV_GO_VERSION:-${CODEX_ENV_GO_VERSION:-}} | |
| CODEX_ENV_SWIFT_VERSION=${CLAUDE_ENV_SWIFT_VERSION:-${CODEX_ENV_SWIFT_VERSION:-}} | |
| CODEX_ENV_PHP_VERSION=${CLAUDE_ENV_PHP_VERSION:-${CODEX_ENV_PHP_VERSION:-}} | |
| CODEX_ENV_JAVA_VERSION=${CLAUDE_ENV_JAVA_VERSION:-${CODEX_ENV_JAVA_VERSION:-}} | |
| echo "Configuring language runtimes..." | |
| # For Python and Node, always run the install commands so we can install | |
| # global libraries for linting and formatting. This just switches the version. | |
| # For others (e.g. rust), to save some time on bootup we only install other language toolchains | |
| # if the versions differ. | |
| if [ -n "${CODEX_ENV_PYTHON_VERSION}" ]; then | |
| echo "# Python: ${CODEX_ENV_PYTHON_VERSION}" | |
| pyenv global "${CODEX_ENV_PYTHON_VERSION}" | |
| python3 --version | |
| fi | |
| if [ -n "${CODEX_ENV_NODE_VERSION}" ]; then | |
| current=$(node -v | cut -d. -f1) # ==> v20 | |
| echo "# Node.js: v${CODEX_ENV_NODE_VERSION} (default: ${current})" | |
| if [ "${current}" != "v${CODEX_ENV_NODE_VERSION}" ]; then | |
| nvm alias default "${CODEX_ENV_NODE_VERSION}" | |
| nvm use --save "${CODEX_ENV_NODE_VERSION}" | |
| corepack enable | |
| fi | |
| fi | |
| # Ruby/Rust/Go/Swift/PHP are not installed in the trimmed image. Each block is | |
| # guarded with `command -v` so that setting one of these env vars degrades to a | |
| # clear warning instead of aborting the whole script under `set -e` (which would | |
| # brick container startup, since the entrypoint runs this before the shell). | |
| if [ -n "${CODEX_ENV_RUBY_VERSION}" ]; then | |
| if command -v ruby >/dev/null 2>&1; then | |
| current=$(ruby -v | cut -d' ' -f2 | cut -d'p' -f1) # ==> 3.2.3 | |
| echo "# Ruby: ${CODEX_ENV_RUBY_VERSION} (default: ${current})" | |
| if [ "${current}" != "${CODEX_ENV_RUBY_VERSION}" ]; then | |
| mise use --global "ruby@${CODEX_ENV_RUBY_VERSION}" | |
| ruby --version | |
| fi | |
| else | |
| echo "# Ruby: requested ${CODEX_ENV_RUBY_VERSION} but ruby is not installed in this image — skipping." | |
| fi | |
| fi | |
| if [ -n "${CODEX_ENV_RUST_VERSION}" ]; then | |
| if command -v rustc >/dev/null 2>&1; then | |
| current=$(rustc --version | awk '{print $2}') # ==> 1.86.0 | |
| echo "# Rust: ${CODEX_ENV_RUST_VERSION} (default: ${current})" | |
| if [ "${current}" != "${CODEX_ENV_RUST_VERSION}" ]; then | |
| rustup default "${CODEX_ENV_RUST_VERSION}" | |
| rustc --version | |
| fi | |
| else | |
| echo "# Rust: requested ${CODEX_ENV_RUST_VERSION} but rustc is not installed in this image — skipping." | |
| fi | |
| fi | |
| if [ -n "${CODEX_ENV_GO_VERSION}" ]; then | |
| if command -v go >/dev/null 2>&1; then | |
| current=$(go version | awk '{print $3}') # ==> go1.23.8 | |
| echo "# Go: go${CODEX_ENV_GO_VERSION} (default: ${current})" | |
| if [ "${current}" != "go${CODEX_ENV_GO_VERSION}" ]; then | |
| mise use --global "go@${CODEX_ENV_GO_VERSION}" | |
| go version | |
| fi | |
| else | |
| echo "# Go: requested ${CODEX_ENV_GO_VERSION} but go is not installed in this image — skipping." | |
| fi | |
| fi | |
| if [ -n "${CODEX_ENV_SWIFT_VERSION}" ]; then | |
| if command -v swift >/dev/null 2>&1; then | |
| current=$(swift --version | sed -n 's/^Swift version \([0-9]\+\.[0-9]\+\).*/\1/p') # ==> 6.2 | |
| echo "# Swift: ${CODEX_ENV_SWIFT_VERSION} (default: ${current})" | |
| if [ "${current}" != "${CODEX_ENV_SWIFT_VERSION}" ]; then | |
| swiftly use "${CODEX_ENV_SWIFT_VERSION}" | |
| swift --version | |
| fi | |
| else | |
| echo "# Swift: requested ${CODEX_ENV_SWIFT_VERSION} but swift is not installed in this image — skipping." | |
| fi | |
| fi | |
| if [ -n "${CODEX_ENV_PHP_VERSION}" ]; then | |
| if command -v php >/dev/null 2>&1; then | |
| current=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') | |
| echo "# PHP: ${CODEX_ENV_PHP_VERSION} (default: ${current})" | |
| if [ "${current}" != "${CODEX_ENV_PHP_VERSION}" ]; then | |
| phpenv global "${CODEX_ENV_PHP_VERSION}snapshot" | |
| php --version | |
| fi | |
| else | |
| echo "# PHP: requested ${CODEX_ENV_PHP_VERSION} but php is not installed in this image — skipping." | |
| fi | |
| fi | |
| if [ -n "${CODEX_ENV_JAVA_VERSION}" ]; then | |
| current=$(java -version 2>&1 | awk -F'[ ."]+' '/version/ {print $3}') | |
| echo "# Java: ${CODEX_ENV_JAVA_VERSION} (default: ${current})" | |
| if [ "${current}" != "${CODEX_ENV_JAVA_VERSION}" ]; then | |
| mise use --global "java@${CODEX_ENV_JAVA_VERSION}" | |
| java -version | |
| fi | |
| fi | |
| SETUP_EOF | |
| cat > verify.sh <<'VERIFY_EOF' | |
| #!/bin/bash --login | |
| set -euo pipefail | |
| echo "Verifying language runtimes ..." | |
| read -ra PYTHON <<< "$PYTHON_VERSIONS" | |
| read -ra NODE <<< "$NODE_VERSIONS" | |
| read -ra JAVA <<< "$JAVA_VERSIONS" | |
| max=$(printf "%s\n" \ | |
| ${#PYTHON[@]} \ | |
| ${#NODE[@]} \ | |
| ${#JAVA[@]} \ | |
| | sort -nr | head -1) | |
| for ((i=max-1; i>=0; i--)); do | |
| CODEX_ENV_PYTHON_VERSION=${PYTHON[i]:-${PYTHON[0]}} \ | |
| CODEX_ENV_NODE_VERSION=${NODE[i]:-${NODE[0]}} \ | |
| CODEX_ENV_JAVA_VERSION=${JAVA[i]:-${JAVA[0]}} \ | |
| bash -lc ' | |
| printf "\n\nTesting setup_universal with versions:\n" | |
| env | grep "^CODEX_ENV_" | sort | |
| printf "\n" | |
| /opt/codex/setup_universal.sh | |
| ' | |
| done | |
| echo "- Python:" | |
| python3 --version | |
| pyenv versions | sed 's/^/ /' | |
| echo "- Node.js:" | |
| node --version | |
| npm --version | |
| pnpm --version | |
| yarn --version | |
| npm ls -g | |
| echo "- Java / Gradle:" | |
| java -version | |
| javac -version | |
| gradle --version | head -n 3 | |
| mvn --version | head -n 1 | |
| echo "All language runtimes detected successfully." | |
| VERIFY_EOF | |
| chmod +x entrypoint.sh setup_universal.sh verify.sh | |
| # Droplet-tuned compose file: detached, long-lived, auto-restart. (The repo's | |
| # own docker-compose.yml is tuned for interactive `compose run` instead.) | |
| cat > docker-compose.yml <<'COMPOSE_EOF' | |
| services: | |
| claude: | |
| build: | |
| context: . | |
| args: | |
| MAKEFLAGS: "-j2" | |
| image: claude-universal:latest | |
| container_name: claude | |
| restart: unless-stopped | |
| stdin_open: true | |
| tty: true | |
| init: true | |
| # Keep the container alive in the background; you exec into it on demand. | |
| command: ["sleep", "infinity"] | |
| volumes: | |
| - claude-workspace:/workspace | |
| - claude-config:/root/.claude | |
| working_dir: /workspace | |
| volumes: | |
| claude-workspace: | |
| claude-config: | |
| COMPOSE_EOF | |
| # --- Build + run ------------------------------------------------------------- | |
| #log "Reclaiming any stale build cache..." | |
| #docker builder prune -af >/dev/null 2>&1 || true | |
| log "Building image (this takes a few minutes)..." | |
| docker compose build --build-arg MAKEFLAGS=-j2 claude | |
| log "Starting claude service (detached)..." | |
| docker compose up -d claude | |
| # --- Install the claude-shell launcher -------------------------------------- | |
| log "Installing 'claude-shell' launcher..." | |
| cat > /usr/local/bin/claude-shell <<'LAUNCHER_EOF' | |
| #!/usr/bin/env bash | |
| # Open a shell (or run a command) inside the long-running claude container. | |
| set -euo pipefail | |
| cd /root/claude-universal | |
| docker compose up -d claude >/dev/null 2>&1 || true | |
| if [ "$#" -eq 0 ]; then | |
| exec docker compose exec claude zsh --login | |
| fi | |
| exec docker compose exec claude "$@" | |
| LAUNCHER_EOF | |
| chmod +x /usr/local/bin/claude-shell | |
| touch "$MARKER" | |
| log "Setup complete. SSH in and run 'claude-shell', then 'claude' to sign in." | |
| # ============================================================================= | |
| # HOW TO USE THIS GIST | |
| # ============================================================================= | |
| # 1. Create a PUBLIC gist with this file's contents (no secrets inside — good). | |
| # 2. Get the RAW URL pinned to a revision, e.g. | |
| # https://gist.githubusercontent.com/<you>/<id>/raw/<REVISION>/cloud-init-gist.sh | |
| # Pin the <REVISION> hash so a future edit can't silently change root-run code. | |
| # 3. Create the droplet with a 2-line user-data bootstrap (this fits in 64KB): | |
| # | |
| # doctl compute droplet create freedcamp-employee-claude \ | |
| # --size s-4vcpu-8gb --region nyc3 --image dockeragent \ | |
| # --ssh-keys <YOUR_KEY_ID> \ | |
| # --user-data '#!/bin/bash | |
| # curl -fsSL "https://gist.githubusercontent.com/<you>/<id>/raw/<REVISION>/cloud-init-gist.sh" -o /root/setup.sh && bash /root/setup.sh > /var/log/claude-setup.log 2>&1' | |
| # | |
| # 4. Watch progress (once SSH is up): ssh root@<ip> 'tail -f /var/log/claude-setup.log' | |
| # Or: cloud-init status --wait | |
| # 5. When done: ssh root@<ip> ; claude-shell ; claude (sign in once) | |
| # ============================================================================= |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment