#!/usr/bin/env bash # # install.sh - run OpenCode's web UI on a Google Cloud Shell VM, keep it alive # in tmux, and give you a browser URL to drive it from. # # The opencode server does NOT run as the SSH command any more. It runs inside a # detached tmux session on the VM, so dropping the tunnel (Ctrl-C, a network # blip, closing the laptop) leaves your work running. Re-attach to it any time. # # Usage: # GH_TOKEN=xxx bash <(curl -fsSL https://.pages.dev/install.sh) # # install.sh start a FRESH instance and tunnel to it (default) # install.sh --reconnect tunnel to the instance already running, untouched # install.sh --stop stop the instance on the VM # install.sh --help # # Two environments are detected automatically: # # * From your own machine - Cloud Shell is started if needed and # http://localhost:$PORT is forwarded back over SSH. # * Pasted into a Cloud Shell terminal - there is no second machine to tunnel # to, so you get a Cloud Shell Web Preview URL instead. # # Why `bash <(curl ...)` and not `curl ... | bash`: stdin has to stay free for # the GH_TOKEN pipe below, and gcloud cloud-shell ssh may prompt. # # Optional environment variables: # PORT Port used on both ends of the tunnel. [default: 4096] # OC_VERSION Version of the opencode-ai npm package. [default: 1.18.35] # GH_TOKEN GitHub token. When set, the VM is logged into GitHub # non-interactively. When unset, that step is skipped silently. # The token is only ever written to a command's stdin - never to # argv, a file, or shell history. # # NEVER add `set -x` to this script: it would echo GH_TOKEN. set -euo pipefail PORT="${PORT:-4096}" OC_VERSION="${OC_VERSION:-1.18.35}" TMUX_SESSION="opencode" # gcloud prepends `DEVSHELL_PROJECT_ID= ` to every --command, and an # assignment prefix may only precede a *simple* command - so a remote script that # opens with `if` or `for` dies with "syntax error near unexpected token `then'". # Every remote script therefore opens with this no-op. (NODE_PRELUDE carries its # own copy, since it is also handed to tmux.) NOOP='true;' # Cloud Shell ships Node inside nvm, and nvm is absent from the PATH of every # non-interactive shell there - plain, interactive and login shells alike. So # npm and opencode are "command not found" on the VM until this runs. NODE_PRELUDE='true; for d in /usr/local/nvm/versions/node/*/bin; do [ -d "$d" ] || continue; PATH="$d:$PATH"; done; export PATH' SETUP_OK='__OC_SETUP_OK__' START_OK='__OC_START_OK__' START_FAILED='__OC_START_FAILED__' NO_TMUX='__OC_NO_TMUX__' ADDON_FAILED='__OC_ADDON_FAILED__' INSTALL_FAILED='__OC_INSTALL_FAILED__' OC_MISSING='__OC_MISSING__' GH_LOGIN_FAILED='__OC_GH_LOGIN_FAILED__' GH_MISSING='__OC_GH_MISSING__' GH_SETUPGIT_FAILED='__OC_GH_SETUPGIT_FAILED__' NO_SESSION='__OC_NO_SESSION__' log() { printf '%s\n' "$*"; } warn() { printf 'warning: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Run OpenCode's web UI on a Google Cloud Shell VM and keep it alive in tmux. GH_TOKEN=xxx bash <(curl -fsSL /install.sh) (no args) start a fresh instance, then tunnel to it --reconnect tunnel to the instance already running, leaving it untouched --stop stop the instance on the VM --help this text From your own machine you get http://localhost:PORT. Pasted into a Cloud Shell terminal you get a Web Preview URL instead. Ctrl-C only closes the tunnel; the opencode server keeps running on the VM. Environment variables: PORT Port used on both ends of the tunnel. (default: 4096) OC_VERSION Version of the opencode-ai npm package. (default: 1.18.35) GH_TOKEN GitHub token; when set, the VM is logged into GitHub non-interactively. When unset, the step is skipped. EOF } action="start" case "${1:-}" in ""|--start) action="start" ;; -r|--reconnect) action="reconnect" ;; -s|--stop) action="stop" ;; -h|--help) usage; exit 0 ;; *) die "unknown argument '$1' (try --help)" ;; esac # ---------------------------------------------------------------- validation -- case "$PORT" in ''|*[!0-9]*) die "PORT must be a number, got '$PORT'." ;; esac [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "PORT must be 1-65535, got $PORT." case "$OC_VERSION" in ''|*[!0-9.A-Za-z-]*) die "OC_VERSION must look like a version, got '$OC_VERSION'." ;; esac # Which environment are we in? /google/devshell only exists on the Cloud Shell VM. inside_cloudshell=0 if [ -d /google/devshell ] || [ -n "${DEVSHELL_PROJECT_ID:-}" ]; then inside_cloudshell=1 fi # ------------------------------------------------------------------ preflight -- ssh_flag_supported=0 if [ "$inside_cloudshell" -eq 0 ]; then if ! command -v gcloud >/dev/null 2>&1; then cat >&2 <<'EOF' error: `gcloud` was not found on your PATH. Install the Google Cloud CLI, then re-run this script: macOS (Homebrew): brew install --cask google-cloud-sdk macOS / Linux: https://cloud.google.com/sdk/docs/install (curl https://sdk.cloud.google.com | bash) After installing, open a new terminal and run: gcloud init # or: gcloud auth login EOF exit 1 fi active_account="$( { gcloud auth list --filter=status:ACTIVE --format='value(account)' 2>/dev/null || true; } | head -n1 )" if [ -z "$active_account" ]; then log "No active gcloud account - starting 'gcloud auth login'..." log "(this opens a browser; approve it, then come back here)" gcloud auth login fi # Older gcloud builds may not accept --ssh-flag; they take ssh flags after `--`. cs_help="$(gcloud cloud-shell ssh --help 2>&1 || true)" case "$cs_help" in *--ssh-flag*) ssh_flag_supported=1 ;; esac fi # Only the tunnelling actions need the local port. if [ "$action" != "stop" ] && [ "$inside_cloudshell" -eq 0 ]; then if (exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then die "local port $PORT is already in use. Free it, or run with a different port: PORT=4097 GH_TOKEN=... bash <(curl -fsSL /install.sh)" fi fi # Hides gcloud's own chatter and this script's internal markers. The checks below # look at the unfiltered text, so filtering here loses nothing. filter_noise() { grep -v \ -e 'Listening on local port' \ -e 'Tunnel stopped' \ -e 'Automatic authentication with GCP CLI tools' \ -e 'Waiting for your Cloud Shell machine to start' \ -e 'Starting your Cloud Shell machine' \ -e '^\.\.*done\.$' \ -e '^__OC_' || true } # Run a script on the VM. In Cloud Shell we are already on the VM, so it just # runs here - which also means stdin flows straight through for GH_TOKEN. on_vm() { if [ "$inside_cloudshell" -eq 1 ]; then bash -c "$1" else gcloud cloud-shell ssh --command="$1" fi } on_vm_capture() { if [ "$inside_cloudshell" -eq 1 ]; then bash -c "$1" 2>&1 else gcloud cloud-shell ssh --command="$1" 2>&1 fi } # --------------------------------------------------------- scripts for the VM -- # The command tmux keeps alive. Single quotes so tmux's own shell expands them. tmux_cmd="$NODE_PRELUDE; exec opencode web --port $PORT --hostname 127.0.0.1" # Install opencode (once per VM) and optionally log in to GitHub. setup_script="$( cat </dev/null 2>&1; then echo '[cloudshell] installing opencode-ai@$OC_VERSION (once per VM, ~20s)' npm i -g --allow-scripts=opencode-ai 'opencode-ai@$OC_VERSION' || { echo '$INSTALL_FAILED'; exit 1; } fi if ! command -v opencode >/dev/null 2>&1; then echo '$OC_MISSING' exit 1 fi echo "[cloudshell] opencode \$(opencode --version 2>/dev/null | head -n1) on \$(node -v)" EOF )" if [ -n "${GH_TOKEN:-}" ]; then setup_script="$setup_script if command -v gh >/dev/null 2>&1; then if gh auth login --with-token; then gh auth setup-git && echo '[cloudshell] GitHub: authenticated, git credentials configured' || echo '$GH_SETUPGIT_FAILED' else echo '$GH_LOGIN_FAILED' fi else echo '$GH_MISSING' fi" fi setup_script="$setup_script echo '$SETUP_OK'" # Stop whatever is running, then start a fresh detached instance and wait until # it actually serves HTTP - not just until the process exists. start_script="$( cat </dev/null 2>&1 || { echo '$NO_TMUX'; exit 1; } tmux kill-session -t $TMUX_SESSION 2>/dev/null || true tmux new-session -d -s $TMUX_SESSION '$tmux_cmd' ready=0 for attempt in \$(seq 1 40); do if curl -fsS -o /dev/null -m 2 http://127.0.0.1:$PORT/ 2>/dev/null; then ready=1; break; fi tmux has-session -t $TMUX_SESSION 2>/dev/null || break sleep 1 done if [ "\$ready" = "1" ]; then echo '$START_OK' else echo '$START_FAILED' echo '--- last lines from the opencode pane ---' tmux capture-pane -p -t $TMUX_SESSION 2>/dev/null | tail -n 25 fi EOF )" # Confirm the existing instance is still alive and serving. reconnect_script="$( cat </dev/null 2>&1 || { echo '$NO_TMUX'; exit 1; } tmux has-session -t $TMUX_SESSION 2>/dev/null || { echo '$NO_SESSION'; exit 1; } if curl -fsS -o /dev/null -m 3 http://127.0.0.1:$PORT/ 2>/dev/null; then echo '$START_OK' else echo '$START_FAILED' tmux capture-pane -p -t $TMUX_SESSION 2>/dev/null | tail -n 25 fi EOF )" stop_script="$( cat </dev/null 2>&1 && tmux has-session -t $TMUX_SESSION 2>/dev/null; then tmux kill-session -t $TMUX_SESSION echo '[cloudshell] opencode stopped.' else echo '[cloudshell] nothing was running.' fi EOF )" # ------------------------------------------------------------------- --stop -- if [ "$action" = "stop" ]; then if [ "$inside_cloudshell" -eq 0 ]; then log "Stopping the opencode instance on Cloud Shell..." fi stop_out="$(on_vm_capture "$stop_script")" printf '%s\n' "$stop_out" | filter_noise exit 0 fi # ------------------------------------------------------------- install setup -- if [ "$action" = "start" ]; then if [ "$inside_cloudshell" -eq 1 ]; then log "Cloud Shell detected - installing opencode on this VM..." else log "Setting up Cloud Shell (starting the VM if it is not running)..." fi setup_rc=0 # printf is a bash builtin, so the token never reaches an argv visible to ps. if [ -n "${GH_TOKEN:-}" ]; then setup_out="$(printf '%s\n' "$GH_TOKEN" | on_vm_capture "$setup_script")" || setup_rc=$? else setup_out="$(on_vm_capture "$setup_script")" || setup_rc=$? fi printf '%s\n' "$setup_out" | filter_noise case "$setup_out" in *"$INSTALL_FAILED"*) die "npm failed to install opencode-ai@$OC_VERSION inside Cloud Shell." ;; *"$OC_MISSING"*) die "opencode is still not on PATH inside Cloud Shell after installing." ;; esac case "$setup_out" in *"$GH_LOGIN_FAILED"*) warn "gh auth login --with-token failed - the token was rejected. Continuing without GitHub auth." ;; *"$GH_MISSING"*) warn "the gh CLI is not available in Cloud Shell - skipping GitHub auth." ;; *"$GH_SETUPGIT_FAILED"*) warn "gh auth setup-git failed - git push over HTTPS may not use the token." ;; esac case "$setup_out" in *"$SETUP_OK"*) ;; *) die "Cloud Shell setup did not complete (exit $setup_rc). See output above." ;; esac fi # ----------------------------------------------------------- start / reconnect -- if [ "$action" = "start" ]; then log "Starting opencode in a tmux session on the VM..." run_script="$start_script" else log "Looking for a running opencode instance on the VM..." run_script="$reconnect_script" fi start_out="$(on_vm_capture "$run_script")" printf '%s\n' "$start_out" | filter_noise case "$start_out" in *"$NO_TMUX"*) die "tmux is missing on the Cloud Shell VM, so opencode cannot be kept alive." ;; esac case "$start_out" in *"$NO_SESSION"*) die "no opencode instance is running on the VM. Start one with: bash <(curl -fsSL /install.sh)" ;; esac case "$start_out" in *"$START_OK"*) ;; *) die "opencode did not come up on the VM (port $PORT never answered). See output above." ;; esac # --------------------------------------------------- Cloud Shell: Web Preview -- if [ "$inside_cloudshell" -eq 1 ]; then log "" log "opencode is running on this VM and will keep running." log "" if [ -n "${WEB_HOST:-}" ]; then log "Open it in your browser:" log " https://$PORT-$WEB_HOST" log " (or click Web Preview in the toolbar -> Change port -> $PORT)" else log "Use Cloud Shell's Web Preview button, port $PORT." fi log "" log "Watch the logs: tmux attach -t $TMUX_SESSION (detach with Ctrl-B then D)" log "Stop it: bash <(curl -fsSL /install.sh) --stop" exit 0 fi # -------------------------------------------------------- tunnel it back here -- remote_run="sleep infinity" run_tunnel() { if [ "$ssh_flag_supported" -eq 1 ]; then gcloud cloud-shell ssh \ --command="$remote_run" \ --ssh-flag="-L $PORT:localhost:$PORT" \ --ssh-flag="-o ServerAliveInterval=30" else # Legacy gcloud: ssh flags are passed through after `--`. gcloud cloud-shell ssh \ --command="$remote_run" \ -- "-L $PORT:localhost:$PORT" "-o ServerAliveInterval=30" fi } log "" log "Open http://localhost:$PORT" log "" log "Ctrl-C closes this tunnel only - opencode keeps running on the VM, and" log "http://localhost:$PORT stops working until you reconnect. To get back to" log "the same running instance without restarting it:" log "" log " bash -c \"\$(curl -fsSL /install.sh)\" --reconnect" log "" # Ctrl-C should read as a clean stop rather than an error. trap 'log ""; log "Tunnel closed. opencode is still running on the VM."; exit 130' INT run_tunnel || die "the tunnel command exited (status $?). If gcloud rejected the SSH flags, update it ('gcloud components update') and retry; otherwise the Cloud Shell VM may have gone away - re-run with --reconnect, or without it to start fresh."