#!/usr/bin/env bash # Build and optionally configure a Docker sandbox interactively. # # The workflow deliberately asks before making choices that affect the new # sandbox. This makes it harder to create a sandbox with the wrong name, # workspace directories, copied data, or credentials. # Stop on command failures, unset variables, and failures hidden in pipelines. set -o errexit set -o nounset set -o pipefail # These values are readonly because they are configuration, not user input. readonly KIT_PATH="$HOME/.sbx/kits/hypoport.ai" readonly TEMPLATE='127.0.0.1:62735/finmas.de/mux033/opencode-ops:latest' # This is intentionally global because the EXIT cleanup function runs after # `main` has returned and therefore cannot access `main`'s local variables. tmp='' require_command() { local command_name="$1" local message="$2" # `command -v` checks the current PATH without printing anything. The # explicit error is easier to understand than a later command-not-found. if ! command -v "$command_name" >/dev/null 2>&1; then printf '%s\n' "$message" >&2 exit 1 fi } ask_yes_no() { local prompt="$1" local answer # Returning success for yes and failure for no lets callers use this # function directly as the condition of an `if` statement. while true; do read -r -p "$prompt [y/n] " answer || exit 0 case "$answer" in [Yy]) return 0 ;; [Nn]) return 1 ;; *) printf 'Please answer yes or no.\n' ;; esac done } prompt_component() { # Bash functions normally cannot assign to a caller's local variable. The # first argument is therefore a variable name, which `printf -v` assigns # to without using unsafe `eval`. local result_var="$1" local label="$2" shift 2 # Arrays keep each menu option as one value, even if it contains spaces. # `custom` is a synthetic final option for values not listed in advance. local options=("$@" custom) local choice custom_value while true; do printf '%s:\n' "$label" # `select` prints a numbered menu and puts the selected text in # `choice`. Invalid numbers produce an empty choice. select choice in "${options[@]}"; do case "$choice" in custom) read -r -p "Custom $label: " custom_value || exit 0 if [[ -n "$custom_value" ]]; then printf -v "$result_var" '%s' "$custom_value" return 0 fi printf 'The custom value cannot be empty.\n' ;; '') printf 'Please select one of the listed options.\n' ;; *) printf -v "$result_var" '%s' "$choice" return 0 ;; esac done done } cleanup_temporary_directory() { # EXIT traps run after `main` returns, so a variable declared local inside # `main` is no longer available. `${tmp:-}` also keeps nounset from # turning normal script completion into an error when no copy was made. if [[ -n "${tmp:-}" ]]; then rm -rf -- "$tmp" fi } main() { # Check dependencies before asking questions. A missing tool should not # leave the user halfway through an interactive setup. require_command sbx 'We cannot build a Docker sbx image because sbx does not seem to be installed.' require_command jq 'We cannot list existing Docker sbx sandboxes because jq does not seem to be installed.' local build_from_scratch if ask_yes_no 'Build from scratch?'; then build_from_scratch=true else build_from_scratch=false fi local sandbox_json # Capture the inventory once. The same snapshot is used for source # selection and cleanup so the cleanup menu cannot unexpectedly include # the sandbox created by this run. if ! sandbox_json=$(sbx ls --json); then printf 'Unable to retrieve the existing Docker sbx sandboxes.\n' >&2 exit 1 fi # A successful command can still return malformed data. `-s` treats the # input as one stream and `-e` makes jq fail when the validation is false. if ! jq -s -e 'length == 1 and (.[0].sandboxes | type == "array")' \ <<< "$sandbox_json" >/dev/null; then printf 'The output from sbx ls --json was not the expected JSON format.\n' >&2 exit 1 fi local -a sandbox_names # `mapfile -t` turns one newline-delimited name per line into a Bash array. # `-r` prevents jq from adding JSON quotes around the names. mapfile -t sandbox_names < <(jq -r '.sandboxes[].name' <<< "$sandbox_json") local base_sandbox_name next_version selected_sandbox local -a current_workspace_paths=() local -a workspace_paths=() if [[ "$build_from_scratch" == true ]]; then # Keep the three name components separate so common combinations are # quick to select while custom values remain possible. prompt_component sandbox_prefix 'Sandbox prefix' 'finmas-gmbh' 'private' prompt_component sandbox_purpose 'Sandbox purpose' 'git-work' 'generic-research' prompt_component sandbox_suffix 'Sandbox suffix' 'finmas-api' 'private-api' base_sandbox_name="${sandbox_prefix}-${sandbox_purpose}-${sandbox_suffix}" next_version=1 else require_command fzf 'We cannot select an existing Docker sbx image because fzf does not seem to be installed.' if ((${#sandbox_names[@]} == 0)); then printf 'No existing Docker sbx sandboxes were found.\n' >&2 exit 1 fi # fzf provides a searchable menu and returns a nonzero status when the # user cancels it. Do not create anything after cancellation. if ! selected_sandbox=$(printf '%s\n' "${sandbox_names[@]}" | fzf \ --prompt='Select Docker sbx image: ' --height=40% --reverse); then printf 'No Docker sbx image was selected.\n' exit 0 fi # Use the already captured JSON rather than querying a changing list. # `--arg` passes the selected name as data, not as jq source code. mapfile -t current_workspace_paths < <( jq -r --arg name "$selected_sandbox" \ '.sandboxes[] | select(.name == $name) | .workspaces[]?' \ <<< "$sandbox_json" ) # A name ending in -v is versioned. Strip that suffix and # increment it; an unversioned source starts at version 1. if [[ "$selected_sandbox" =~ ^(.*)-v([0-9]+)$ ]]; then base_sandbox_name="${BASH_REMATCH[1]}" next_version=$((10#${BASH_REMATCH[2]} + 1)) else base_sandbox_name="$selected_sandbox" next_version=1 fi fi # Construct the final name in one place for both build modes. local new_sandbox_name="${base_sandbox_name}-v${next_version}" # Arrays are important here: each workspace path remains one argument to # `sbx create`, even when a path contains whitespace. if [[ "$build_from_scratch" == true && ( "$sandbox_prefix" != 'finmas-gmbh' || "$sandbox_suffix" == 'private-api' ) ]]; then workspace_paths=("$HOME/docker-sbx/empty") else workspace_paths=("$HOME/Documents/git") fi if [[ "$build_from_scratch" == true ]]; then current_workspace_paths=("${workspace_paths[@]}") fi printf 'Workspace directories in the current sandbox:\n' if ((${#current_workspace_paths[@]} == 0)); then printf 'No workspace directories were reported.\n' else printf '%s\n' "${current_workspace_paths[@]}" fi # Additional paths are appended rather than replacing the default mount. if ask_yes_no 'Do you want to add additional workspace directories?'; then local workspace_path while true; do read -r -p 'Additional workspace path (press Enter when finished): ' workspace_path || exit 0 [[ -z "$workspace_path" ]] && break workspace_paths+=("$workspace_path") done fi local api_key if [[ "$build_from_scratch" == false ]]; then # `sbx cp` needs a host-side intermediary. `mktemp` gives concurrent # runs separate directories, and the EXIT trap removes the copy even # if a later command fails. tmp=$(mktemp -d) || { printf 'Unable to create a temporary directory.\n' >&2 exit 1 } trap cleanup_temporary_directory EXIT # Copy before creating the new sandbox so a failed creation cannot # destroy the only temporary copy of the source data. if ! sbx cp "${selected_sandbox}:/home/agent/.local" "$tmp"; then printf 'Unable to copy /home/agent/.local from Docker sbx image: %s\n' \ "$selected_sandbox" >&2 exit 1 fi fi printf 'Creating Docker sbx image: %s\n' "$new_sandbox_name" if ! sbx create opencode \ --name "$new_sandbox_name" \ --kit "$KIT_PATH" \ --template "$TEMPLATE" \ "${workspace_paths[@]}"; then printf 'Unable to create Docker sbx image: %s\n' "$new_sandbox_name" >&2 exit 1 fi if [[ "$build_from_scratch" == false ]]; then if ! sbx cp "$tmp/.local" "${new_sandbox_name}:/home/agent/"; then printf 'Unable to copy the temporary .local directory into Docker sbx image: %s\n' \ "$new_sandbox_name" >&2 exit 1 fi fi # `read -s` prevents the API key from being echoed. The newline afterward # keeps subsequent terminal output readable. if ! read -rsp 'HYPOPORT_API_KEY: ' api_key; then printf '\nUnable to read the HYPOPORT_API_KEY.\n' >&2 exit 1 fi printf '\n' # Pipe the secret to `sbx exec` instead of placing it in command arguments, # where process inspection could expose it. `%q` writes a shell-escaped # value, and `umask 077` limits the generated file to its owner. if ! printf '%s\n' "$api_key" | sbx exec "$new_sandbox_name" bash -c \ 'umask 077; read -r api_key; printf "export HYPOPORT_API_KEY=%q\n" "$api_key" > /etc/sandbox-persistent.sh'; then unset api_key printf 'Unable to store the HYPOPORT_API_KEY in Docker sbx image: %s\n' \ "$new_sandbox_name" >&2 exit 1 fi unset api_key # Cleanup is offered only after the new sandbox has been fully configured. # An empty original inventory has nothing to display or remove. if ((${#sandbox_names[@]} == 0)) || ! ask_yes_no 'Do you want to delete existing Docker sbx sandboxes?'; then return 0 fi printf 'Existing Docker sbx sandboxes:\n' local index for index in "${!sandbox_names[@]}"; do printf ' %d) %s\n' "$((index + 1))" "${sandbox_names[index]}" done local selection selection_part range_start range_end local valid_selection index sandbox_name declare -A selected_indices=() while true; do read -r -p 'Sandboxes to delete (for example, 1-3 or 1, 2, 7; press Enter to cancel): ' selection || return 0 [[ -z "$selection" ]] && return 0 # An associative array is used as a set, so selecting an item twice # still deletes it only once. selected_indices=() valid_selection=true IFS=',' read -ra selection_parts <<< "$selection" for selection_part in "${selection_parts[@]}"; do selection_part="${selection_part//[[:space:]]/}" # Remove spaces around commas and hyphens before parsing. Validate # every part before running the first destructive command. if [[ "$selection_part" =~ ^([0-9]+)-([0-9]+)$ ]]; then range_start=$((10#${BASH_REMATCH[1]})) range_end=$((10#${BASH_REMATCH[2]})) if ((range_start < 1 || range_start > range_end || range_end > ${#sandbox_names[@]})); then valid_selection=false break fi # Expand an inclusive range such as 1-3 into its menu indexes. for ((index = range_start; index <= range_end; index++)); do selected_indices[$index]=1 done elif [[ "$selection_part" =~ ^[0-9]+$ ]]; then # Bash arrays are zero-based, but the displayed menu is # intentionally one-based for people entering selections. index=$((10#$selection_part)) if ((index < 1 || index > ${#sandbox_names[@]})); then valid_selection=false break fi selected_indices[$index]=1 else valid_selection=false break fi done if [[ "$valid_selection" != true || ${#selected_indices[@]} -eq 0 ]]; then printf 'Please enter valid sandbox numbers and ranges from 1 to %d.\n' "${#sandbox_names[@]}" continue fi # Iterate in menu order instead of associative-array order so output # and deletion operations are predictable. for ((index = 1; index <= ${#sandbox_names[@]}; index++)); do if [[ ${selected_indices[$index]:-} == 1 ]]; then sandbox_name="${sandbox_names[index - 1]}" printf 'Deleting Docker sbx sandbox: %s\n' "$sandbox_name" if ! sbx rm --force "$sandbox_name"; then printf 'Unable to delete Docker sbx sandbox: %s\n' "$sandbox_name" >&2 fi fi done return 0 done } main "$@"