#!/bin/bash # run-app — start the source app inside the Explore container with one command. # # Before this existed you had to open two shells into the container and start # the server and client by hand. This wraps that up: it makes sure postgres is # running, starts each process in the background, waits until they're actually # listening, and prints the URL to open plus a login. Logs are written to a # file so the foreground stays clean. # # Usage: # run-app start the app (no-op if it's already running) # run-app --restart stop, then start again # run-app --stop stop the app # run-app --logs follow the server + client logs (Ctrl-C to stop following) # run-app --status show whether the app is running # run-app --help this message set -uo pipefail RUN_DIR="/tmp/raccoon-app" mkdir -p "$RUN_DIR" CYAN='\033[1;36m'; YELLOW='\033[1;33m'; GRAY='\033[0;90m'; RED='\033[1;31m'; RESET='\033[0m' REPO_NAME=$(node -e "try{process.stdout.write(require('/workspace/toolkit.json').repo)}catch{}" 2>/dev/null || true) # Host port the browser uses. The app always binds the container ports (3000 / # 3001); the Explore container publishes them on a host port that defaults per # repo but can be overridden (so more than one container — even of the same # repo — can run at once). That live value is exported into the container as # $EXPLORE_CLIENT_PORT; prefer it, falling back to toolkit.json then 3000 for # older containers built before this var existed. CLIENT_HOST_PORT="${EXPLORE_CLIENT_PORT:-$(node -e "try{process.stdout.write(String(require('/workspace/toolkit.json').explorePorts.clientHost))}catch{process.stdout.write('3000')}" 2>/dev/null || echo 3000)}" # --- process helpers --------------------------------------------------------- # Is the process recorded in $1 (a pidfile) still alive? _alive() { local pf="$1"; [ -f "$pf" ] && kill -0 "$(cat "$pf" 2>/dev/null)" 2>/dev/null; } # Start a backgrounded process group leader so we can later kill the whole # group (vite/tsx spawn children). setsid makes the started process its own # session+group leader; we record its pid (== the group id). _spawn() { local name="$1" workdir="$2" cmd="$3" local log="$RUN_DIR/$name.log" pf="$RUN_DIR/$name.pid" : > "$log" if command -v setsid >/dev/null 2>&1; then setsid bash -c "cd '$workdir' && exec $cmd" >"$log" 2>&1 & else # Fallback: no setsid (children may outlive a stop; best-effort). ( cd "$workdir" && exec $cmd ) >"$log" 2>&1 & fi echo $! > "$pf" } # Stop the process recorded in pidfile $1 (and its group, when we have one). _kill_pidfile() { local pf="$1"; [ -f "$pf" ] || return 0 local pid; pid=$(cat "$pf" 2>/dev/null || true) if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || break; sleep 0.3; done kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true fi rm -f "$pf" } # Wait (bounded) until something is listening on TCP port $1. _wait_tcp() { local port="$1" tries="${2:-180}" i for ((i = 0; i < tries; i++)); do if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; return 0; fi sleep 1 done return 1 } # --- actions ----------------------------------------------------------------- stop_app() { local stopped=0 for pf in "$RUN_DIR"/*.pid; do [ -e "$pf" ] || continue _kill_pidfile "$pf" stopped=1 done if [ "$stopped" = 1 ]; then printf "${GRAY}Stopped the app.${RESET}\n"; else printf "${GRAY}Nothing to stop.${RESET}\n"; fi } status_app() { local any=0 for pf in "$RUN_DIR"/*.pid; do [ -e "$pf" ] || continue local name; name=$(basename "$pf" .pid) if _alive "$pf"; then printf " ${GRAY}%-8s${RESET} running (pid %s)\n" "$name" "$(cat "$pf")"; else printf " ${GRAY}%-8s${RESET} not running\n" "$name"; fi any=1 done [ "$any" = 1 ] || printf "${GRAY}App is not running.${RESET}\n" } logs_app() { local logs=() for lf in "$RUN_DIR"/*.log; do [ -e "$lf" ] && logs+=("$lf"); done if [ "${#logs[@]}" -eq 0 ]; then printf "${GRAY}No logs yet — start the app first with ${RESET}run-app\n"; return 0; fi printf "${GRAY}Following %s (Ctrl-C to stop following; the app keeps running):${RESET}\n" "${logs[*]}" tail -n +1 -f "${logs[@]}" } # Start helpers per repo. Each starts the process(es) on their container ports. start_palolo() { _spawn server /workspace/repo/packages/server "node --import=tsx src/server.ts" _spawn client /workspace/repo/packages/client "npx vite --host 0.0.0.0 --port 3000" printf " ${CYAN}\xe2\x96\xb6${RESET} starting server (packages/server)\xe2\x80\xa6\n" printf " ${CYAN}\xe2\x96\xb6${RESET} starting client (packages/client)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up\xe2\x80\xa6${RESET}\n" local ok_server=1 ok_client=1 _wait_tcp 3001 || ok_server=0 _wait_tcp 3000 || ok_client=0 if [ "$ok_server" = 1 ] && [ "$ok_client" = 1 ]; then printf " ${CYAN}\xe2\x9c\x85 app is up${RESET}\n" printf " open ${CYAN}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" printf " login ${GRAY}zaniyah@exhalefi.com${RESET} / ${GRAY}test${RESET}\n" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET} (server=%s client=%s)\n" "$ok_server" "$ok_client" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/{server,client}.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } start_zenbill() { _spawn app /workspace/repo "bundle exec rails server -b 0.0.0.0 -p 3000" printf " ${YELLOW}\xe2\x96\xb6${RESET} starting Rails (puma)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up\xe2\x80\xa6${RESET}\n" if _wait_tcp 3000; then printf " ${YELLOW}\xe2\x9c\x85 app is up${RESET}\n" printf " open ${YELLOW}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" printf " ${GRAY}note: this app routes by subdomain. Plain localhost shows only the${RESET}\n" printf " ${GRAY}Rails welcome page; the real UI needs /etc/hosts entries for${RESET}\n" printf " ${GRAY}app.dev.zenbill.com etc. (see README \xe2\x86\x92 Running the app).${RESET}\n" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET}\n" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/app.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } start_zeta_heimdall() { # API-only Rails app — boots a JSON API on container port 3000 (no separate client). _spawn app /workspace/repo "bundle exec rails server -b 0.0.0.0 -p 3000" printf " ${YELLOW}\xe2\x96\xb6${RESET} starting Rails API (puma)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up\xe2\x80\xa6${RESET}\n" if _wait_tcp 3000; then printf " ${YELLOW}\xe2\x9c\x85 app is up${RESET}\n" printf " base ${YELLOW}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" printf " ${GRAY}note: this is a JSON API, not a UI \xe2\x80\x94 hit an endpoint (e.g. an auth route)${RESET}\n" printf " ${GRAY}rather than expecting a page in the browser.${RESET}\n" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET}\n" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/app.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } start_zeta_platform() { # Boots BOTH the Rails API and the React client so the full UI comes up. # The client (Create React App, react-scripts 2.1.1) serves the UI on container # :3000 (the published port) and proxies /graphql to the Rails API, which its # package.json "proxy" hardcodes at localhost:5000. So Rails binds :5000 (reached # only from inside the container — the browser talks solely to the client) and the # client binds :3000. rspec doesn't need any of this; it's just the interactive app. # # react-scripts 2.1.1 is webpack-4 era: on Node 17+ its build hashing crashes # without --openssl-legacy-provider. HOST=0.0.0.0 + DANGEROUSLY_DISABLE_HOST_CHECK # let the dev server answer requests arriving via the published host port. # node_modules is the container-local symlink post-create.sh set up; yarn is v1. _spawn server /workspace/repo "env PORT=5000 bundle exec rails server -b 0.0.0.0 -p 5000" _spawn client /workspace/repo "env NODE_OPTIONS=--openssl-legacy-provider BROWSER=none CI=false PORT=3000 HOST=0.0.0.0 DANGEROUSLY_DISABLE_HOST_CHECK=true NODE_PATH=src:src/components/ ./node_modules/.bin/react-app-rewired start" printf " ${YELLOW}\xe2\x96\xb6${RESET} starting Rails API (puma) on :5000\xe2\x80\xa6\n" printf " ${CYAN}\xe2\x96\xb6${RESET} starting React client (react-scripts)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up (first client compile takes a minute)\xe2\x80\xa6${RESET}\n" local ok_server=1 ok_client=1 _wait_tcp 5000 || ok_server=0 _wait_tcp 3000 || ok_client=0 if [ "$ok_server" = 1 ] && [ "$ok_client" = 1 ]; then printf " ${CYAN}\xe2\x9c\x85 app is up${RESET}\n" printf " open ${CYAN}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" printf " ${GRAY}note: that URL is the React UI. It proxies GraphQL to the Rails API on${RESET}\n" printf " ${GRAY}:5000 inside the container (reach it directly from a container shell at${RESET}\n" printf " ${GRAY}http://localhost:5000). The DB is schema-loaded but unseeded \xe2\x80\x94 you may need${RESET}\n" printf " ${GRAY}to create an account/records to see much in the UI.${RESET}\n" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET} (server=%s client=%s)\n" "$ok_server" "$ok_client" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/{server,client}.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } start_flaredown() { # Polyglot single-container app: the Rails API (backend/) + the Ember client (frontend/). # The Ember dev server serves the UI on container :3000 (the published port) and proxies # API calls to the Rails backend, which docker-compose runs on :3000 too — here the client # takes :3000, so the API binds :5000 (reached only from inside the container) and the # client proxies to it. rspec needs neither the client nor the running server. Node 14 # (from nvm) drives ember-cli; Ruby 3.2.3 is the image default. OPENSSL_CONF=/dev/null # lets the old webpack md4 hashing run on bookworm's OpenSSL 3. local NODE14_BIN NODE14_BIN=$(ls -d /usr/local/nvm/versions/node/v14.* 2>/dev/null | sort -V | tail -1)/bin _spawn server /workspace/repo/backend "env PORT=5000 bundle exec rails server -b 0.0.0.0 -p 5000" _spawn client /workspace/repo/frontend "env PATH=$NODE14_BIN:\$PATH OPENSSL_CONF=/dev/null ./node_modules/.bin/ember serve --port 3000 --proxy http://localhost:5000" printf " ${YELLOW}\xe2\x96\xb6${RESET} starting Rails API (puma) on :5000\xe2\x80\xa6\n" printf " ${CYAN}\xe2\x96\xb6${RESET} starting Ember client (ember-cli)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up (first Ember build takes a minute)\xe2\x80\xa6${RESET}\n" local ok_server=1 ok_client=1 _wait_tcp 5000 || ok_server=0 _wait_tcp 3000 || ok_client=0 if [ "$ok_server" = 1 ] && [ "$ok_client" = 1 ]; then printf " ${CYAN}\xe2\x9c\x85 app is up${RESET}\n" printf " open ${CYAN}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" printf " ${GRAY}note: that URL is the Ember UI; it proxies API calls to the Rails backend on${RESET}\n" printf " ${GRAY}:5000 inside the container. The DBs (Postgres + MongoDB) are migrated but${RESET}\n" printf " ${GRAY}unseeded \xe2\x80\x94 register a user in the UI to see much.${RESET}\n" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET} (server=%s client=%s)\n" "$ok_server" "$ok_client" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/{server,client}.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } start_breezy_complete() { # Monorepo: Rails API (backend/, container :3001) + Next.js frontend (frontend/, # container :3000). The offline Clerk-bypass env (DISABLE_CLERK etc.) is injected # HERE, not baked into the image, so a worker's bare `bundle exec rspec` keeps # upstream CI's env (ambient DISABLE_CLERK 403s several controller specs). # NEXT_PUBLIC_BACKEND_URL must be the HOST-visible backend URL — the browser # calls it — so derive it from the live published server port. Sidekiq is not # started (only needed for background-job behavior; LLM-dependent jobs degrade # keyless anyway). local server_host_port server_host_port="${EXPLORE_SERVER_PORT:-$(node -e "try{process.stdout.write(String(require('/workspace/toolkit.json').explorePorts.serverHost))}catch{process.stdout.write('4001')}" 2>/dev/null || echo 4001)}" _spawn server /workspace/repo/backend "env DISABLE_CLERK=true CLERK_SKIP_RAILTIE=true bundle exec rails server -b 0.0.0.0 -p 3001" _spawn client /workspace/repo/frontend "env NEXT_PUBLIC_BACKEND_URL=http://localhost:${server_host_port} npm run dev -- -H 0.0.0.0 -p 3000" printf " ${YELLOW}\xe2\x96\xb6${RESET} starting Rails API (backend/) on :3001\xe2\x80\xa6\n" printf " ${CYAN}\xe2\x96\xb6${RESET} starting Next.js frontend (frontend/)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up\xe2\x80\xa6${RESET}\n" local ok_server=1 ok_client=1 _wait_tcp 3001 || ok_server=0 _wait_tcp 3000 || ok_client=0 if [ "$ok_server" = 1 ] && [ "$ok_client" = 1 ]; then printf " ${CYAN}\xe2\x9c\x85 app is up${RESET}\n" printf " open ${CYAN}http://localhost:%s/pro_signin${RESET}\n" "$CLIENT_HOST_PORT" printf " ${GRAY}auth is bypassed offline \xe2\x80\x94 /pro_signin auto-redirects to the seeded${RESET}\n" printf " ${GRAY}professional's dashboard (no login needed). Enter via /pro_signin, not a${RESET}\n" printf " ${GRAY}bookmarked dashboard URL \xe2\x80\x94 those embed a token that changes on re-seed.${RESET}\n" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET} (server=%s client=%s)\n" "$ok_server" "$ok_client" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/{server,client}.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } # ---- polyglot mode ----------------------------------------------------------- # A polyglot toolkit (toolkit.json .polyglot=true) hosts many member repos under # repos//. The worker picks one with `run-app `; its deps + DB install on # first use (deferred), then its app boots on container port 3000. Dispatch is # RUNTIME-DRIVEN: each member carries a `runtime` ("ruby:3.2.1" | "node:16" | # "python:3.10" | "none") and an optional `startCmd` in toolkit.json, so there is no # per-repo hardcoding (scales to all repos). rbenv/pyenv shims must be on PATH inside # the backgrounded process (a non-login shell), hence the explicit env prefixes. RBENV_PATH='/usr/local/rbenv/shims:/usr/local/rbenv/bin' PYENV_PATH='/usr/local/pyenv/shims:/usr/local/pyenv/bin' _is_polyglot() { node -e "try{process.exit(require('/workspace/toolkit.json').polyglot?0:1)}catch{process.exit(1)}" 2>/dev/null; } _poly_repos() { node -e "require('/workspace/toolkit.json').repos.forEach(r=>console.log(r.repo))" 2>/dev/null; } _poly_default() { node -e "process.stdout.write(require('/workspace/toolkit.json').defaultRepo||'')" 2>/dev/null; } # _poly_field → the member's field value ('' if absent). Args passed via # argv (not interpolated) so a repo name can't break the JS. _poly_field() { node -e "const r=require('/workspace/toolkit.json').repos.find(x=>x.repo===process.argv[1]);process.stdout.write(r&&r[process.argv[2]]!=null?String(r[process.argv[2]]):'')" "$1" "$2" 2>/dev/null; } # Is rbenv/pyenv version installed in this image? (EOL runtimes won't be.) _rb_have() { [ -d "/usr/local/rbenv/versions/$1" ]; } _py_have() { [ -d "/usr/local/pyenv/versions/$1" ]; } # Newer estate images ship Python via uv (a system python3 + `uv`) instead of pyenv. # True when there's no pyenv build for but uv can provide it — the python arms # then fall back to a container-local uv venv per member. _py_uv_ok() { ! _py_have "$1" && command -v uv >/dev/null 2>&1; } _uv_venv_dir() { printf '/opt/raccoon-venvs/%s' "$1"; } # Node is multi-version via nvm. Resolve a member's node spec (e.g. "16" or # "16.20.2") to that major's installed node bin dir, or '' if that major isn't in # the image (so the selector can fall back to explore-only). Picks the highest # installed patch of the requested major. _node_bin() { local major="${1%%.*}" nvm_dir="${NVM_DIR:-/usr/local/nvm}" d d=$(ls -d "$nvm_dir"/versions/node/v"$major".* 2>/dev/null | sort -V | tail -1) [ -n "$d" ] && printf '%s/bin' "$d" } # Symlink ./node_modules (cwd = the dir being installed) to a container-local tree keyed by # — see the ENFILE rationale at the call site. The target must itself be named # `node_modules` (Node resolves the symlink, then walks ancestors for that literal name), # and its parent needs a stub manifest: postinstall scripts that locate the project by # truncating their realpath at `node_modules` require() `/package.json`. _nm_link() { local root="/opt/raccoon-node-modules/$1" [ -L node_modules ] || rm -rf node_modules mkdir -p "$root/node_modules" [ -f "$root/package.json" ] \ || printf '{"name":"raccoon-node-modules-root","version":"0.0.0","private":true}\n' > "$root/package.json" ln -sfn "$root/node_modules" node_modules } # Rewrite poetry deps of the form ` = { git = "ssh://git@github.com/AskZeta/.git", rev=… }` # in to a local path dep at /workspace/repos/zeta-. The sibling repo is a # member of this toolkit, so the path resolves offline (no SSH key / network needed). # NB: uses `|` as the s/// delimiter, NOT `{}` — the pattern has `[^}]` and the replacement has # `{ … }`, which break perl's brace-balanced delimiter parsing. _rewrite_askzeta_git_deps() { perl -i -pe 's|=\s*\{\s*git\s*=\s*"ssh://git\@github\.com/AskZeta/([^"]+?)(?:\.git)?"\s*,[^}]*\}|= { path = "/workspace/repos/zeta-\L$1\E", develop = false }|g' "$1" 2>/dev/null || true } # Create + schema-load EVERY database of a multi-DB Rails app for one RAILS_ENV ($1). # # Rails only defines the namespaced `db:schema:load:` tasks when more than one config # is VISIBLE to rake, and a config marked `database_tasks: false` is hidden from # `configs_for`. zeta-plastic marks `source` hidden in development and BOTH connections # hidden in test, so it has no namespaced tasks at all: the commands below fail with # `UnrecognizedCommandError`, and plain `db:schema:load` can't reach the extra DB anyway. # So: try the namespaced path (px-api has it), else walk the configs ourselves. # # NEVER db:migrate — its implicit schema:dump regenerates db/source_schema.rb from the # near-empty source DB, truncating the real file (3487 -> ~77 lines). _multidb_setup_env() { local e="$1" RAILS_ENV="$e" DISABLE_SPRING=1 bundle exec rails db:create 2>/dev/null if RAILS_ENV="$e" DISABLE_SPRING=1 bundle exec rails db:schema:load:primary >/dev/null 2>&1; then RAILS_ENV="$e" DISABLE_SPRING=1 bundle exec rails db:schema:load:source >/dev/null 2>&1 || true return 0 fi # Fallback: create and load each config, hidden ones included. Two traps, both hit in # practice on zeta-plastic: (1) `create` raises DatabaseAlreadyExists once the db:create # above has made the primary DB, and that path leaves ActiveRecord connected to the # `postgres` MAINTENANCE database; (2) load_schema does not connect on its own (Rails # 7.2) — it loads into whatever connection is current. Without the explicit # establish_connection below, the app's tables get created inside `postgres` and the # real DB is left empty, with every command still reporting success. # Ruby goes to a real temp file, not /dev/stdin — `rails runner` Kernel.loads the path, # which needs a seekable file, and a heredoc is a pipe on some shells. local rb; rb=$(mktemp /tmp/raccoon-load-schemas.XXXXXX.rb) cat > "$rb" <<'RUBY' ActiveRecord::Base.configurations .configs_for(env_name: Rails.env, include_hidden: true) .reject(&:replica?).each do |c| begin ActiveRecord::Tasks::DatabaseTasks.create(c) rescue ActiveRecord::DatabaseAlreadyExists, ActiveRecord::StatementInvalid end dump = c.schema_dump || "schema.rb" file = Rails.root.join("db", dump) next unless File.exist?(file) ActiveRecord::Base.establish_connection(c) ActiveRecord::Tasks::DatabaseTasks.load_schema(c, :ruby, file.to_s) puts "loaded db/#{dump} -> #{c.database}" end RUBY # "already exists" is expected for the DB db:create just made — not worth showing. RAILS_ENV="$e" DISABLE_SPRING=1 bundle exec rails runner "$rb" 2>&1 \ | grep -v "already exists" | sed 's/^/ /' rm -f "$rb" return 0 } # First-use setup for a member repo: checkout its commit, install deps, prepare DB. # Idempotent via a marker file. Runtime-driven; the marker is written only on success. setup_repo() { local repo="$1" dir="/workspace/repos/$1" marker="/workspace/repos/$1/.raccoon-setup-done" [ -f "$marker" ] && return 0 local commit runtime kind ver bootenv setupcmd commit=$(_poly_field "$repo" defaultCommit) runtime=$(_poly_field "$repo" runtime); kind=${runtime%%:*}; ver=${runtime#*:} # A member's optional bootEnv ("KEY=val KEY2=val2") supplies dummy values for vars an # app reads at class-load that its .env.example omits (e.g. wasabi-platform's IVR_UN/ # IVR_PW). dotenv does NOT reliably load .env into the rspec process for some apps, so # the load-bearing channel is real shell exports in ~/.bashrc (below) — the worker's # `bundle exec rspec` then sees them. The .env append (in each runtime case) is belt- # and-suspenders for dotenv-loading apps. Keeps real secrets out; just unblocks boot. bootenv=$(_poly_field "$repo" bootEnv) # A member's optional setupCmd runs ONCE here, after deps are installed, for one-time # app preparation that isn't boot (schema push, seeding, generating a gitignored asset). # It belongs here rather than in startCmd: startCmd runs on every `run-app`, so seeding # from there re-runs on each boot and its output is mixed into the server log. Failure # is non-fatal (a warning) — a member that can still be explored shouldn't be blocked by # a seed hiccup, mirroring the `|| true` seeds in post-create.sh for single-repo kits. setupcmd=$(_poly_field "$repo" setupCmd) if [ -n "$commit" ] && ! git -C "$dir" -c advice.detachedHead=false checkout "$commit" >/dev/null 2>&1; then printf "${RED}checkout %s failed for %s${RESET}\n" "$commit" "$repo"; return 1 fi # Keep the setup marker out of `git status` — and out of snapshot patches, which # capture the worker's repo state (mirrors post-create's .pnpm-store exclude; the # create-snapshot checkpoint hook excludes it as well). mkdir -p "$dir/.git/info" grep -qxF '.raccoon-setup-done' "$dir/.git/info/exclude" 2>/dev/null \ || printf '\n# raccoon-explore: run-app first-use setup marker\n.raccoon-setup-done\n' >> "$dir/.git/info/exclude" # Persist bootEnv as real exports for ALL the worker's container shells (deduped per repo). if [ -n "$bootenv" ] && ! grep -q "raccoon-bootenv:$repo" "$HOME/.bashrc" 2>/dev/null; then { echo "# raccoon-bootenv:$repo"; for kv in $bootenv; do echo "export $kv"; done; } >> "$HOME/.bashrc" fi printf " ${GRAY}first-time setup for %s (%s) \xe2\x80\x94 runs once\xe2\x80\xa6${RESET}\n" "$repo" "${runtime:-explore-only}" case "$kind" in ruby) _rb_have "$ver" || { printf " ${GRAY}(Ruby %s not in this image; skipping deps \xe2\x80\x94 explore-only)${RESET}\n" "$ver"; touch "$marker"; return 0; } ( cd "$dir" \ && export PATH="$RBENV_PATH:$PATH" RBENV_VERSION="$ver" \ && { [ -f config/database.yml.example ] && cp -n config/database.yml.example config/database.yml; true; } \ && { [ -f .env.example ] && cp -n .env.example .env; true; } \ && { [ -n "$bootenv" ] && printf '%s\n' $bootenv >> .env; true; } \ && { bundle lock --add-platform x86_64-linux aarch64-linux >/dev/null 2>&1 || true; } \ && { bundle install || bundle install --full-index; } \ && { if [ -f db/source_schema.rb ]; then \ # MULTI-DATABASE (px-api, plastic): the `users` etc. live in the `source` DB. # Load EACH db's schema for dev AND test. DISABLE_SPRING so a preloaded # stale connection doesn't make the source load a silent no-op. for e in development test; do \ _multidb_setup_env "$e" || true; \ done; \ else \ # Single-DB: prepare the dev DB (rails_helper often needs it present), then # load + migrate the test DB (migrate is a no-op when schema.rb is current, # and applies pending migrations when it's stale). bundle exec rails db:prepare 2>/dev/null || bundle exec rails db:create db:schema:load 2>/dev/null || true; \ RAILS_ENV=test bundle exec rails db:create 2>/dev/null; \ RAILS_ENV=test bundle exec rails db:schema:load 2>/dev/null; \ RAILS_ENV=test bundle exec rails db:migrate 2>/dev/null || true; \ fi; } ) || return 1 ;; node) nbin=$(_node_bin "$ver") [ -z "$nbin" ] && { printf " ${GRAY}(Node %s not in this image; skipping deps \xe2\x80\x94 explore-only)${RESET}\n" "$ver"; touch "$marker"; return 0; } # Install node_modules to a CONTAINER-LOCAL path, not the bind-mounted repo dir. On # macOS Docker Desktop the repo is a host bind mount; writing a huge node_modules tree # across the file-sharing layer is slow AND exhausts the HOST's open-file table (ENFILE # "file table overflow"), which can take the whole machine down — not just the install. # Keeping node_modules inside the Linux VM confines that churn to the VM. The repo stays # bind-mounted (the worker sees their edits); node_modules is reached via a symlink. ( cd "$dir" \ && export PATH="$nbin:$PATH" \ && _nm_link "$repo" \ && { [ -f .env.example ] && cp -n .env.example .env; true; } \ && { [ -n "$bootenv" ] && printf '%s\n' $bootenv >> .env; true; } \ && { if [ -f yarn.lock ]; then yarn install; elif [ -f package-lock.json ]; then npm install; else yarn install; fi; } ) || return 1 ;; python) if _py_uv_ok "$ver"; then # uv-python image (clockwise-polyglot era): container-local venv per member, # deps via uv. `uv pip install -e .` handles poetry-backend pyprojects too. local vdir; vdir=$(_uv_venv_dir "$repo") ( cd "$dir" \ && uv venv "$vdir" -p "$ver" -q \ && . "$vdir/bin/activate" \ && { [ -f .env.example ] && cp -n .env.example .env; true; } \ && { [ -n "$bootenv" ] && printf '%s\n' $bootenv >> .env; true; } \ && { if [ -f pyproject.toml ]; then uv pip install -q -e . || uv pip install -q -r requirements.txt 2>/dev/null || true; \ elif [ -f requirements.txt ]; then uv pip install -q -r requirements.txt; \ elif [ -f server/requirements.txt ]; then uv pip install -q -r server/requirements.txt; \ elif [ -f setup.py ]; then uv pip install -q -e .; else true; fi; } ) || return 1 touch "$marker"; return 0 fi _py_have "$ver" || { printf " ${GRAY}(Python %s not in this image; skipping deps \xe2\x80\x94 explore-only)${RESET}\n" "$ver"; touch "$marker"; return 0; } # Some poetry repos depend on sibling repos via `git = "ssh://git@github.com/AskZeta/.git"`, # which can't resolve in the container (no SSH key, no network). The deps are TRANSITIVE # (cx-chatbot → compiler-agent → agent-tools → leaves), so rewrite the target AND every # sibling pyproject to local path deps — else poetry shells out to `ssh` for a transitive # git dep and fails ("No such file or directory: 'ssh'"). for pp in /workspace/repos/*/pyproject.toml; do [ -f "$pp" ] && _rewrite_askzeta_git_deps "$pp" done ( cd "$dir" && export PATH="$PYENV_PATH:$PATH" PYENV_VERSION="$ver" \ && { [ -f .env.example ] && cp -n .env.example .env; true; } \ && { [ -n "$bootenv" ] && printf '%s\n' $bootenv >> .env; true; } \ && { if [ -f pyproject.toml ]; then \ # The git→path rewrite invalidates poetry.lock ("changed significantly"); # regenerate it before installing. Poetry 2.x `lock` preserves pins by # default (the old `--no-update` flag was removed in 2.0). poetry lock 2>/dev/null || true; \ # --no-root: install deps only, not the project package itself. Some members' # pyproject package name doesn't map to a folder poetry can find ("No file/folder # found for package "), which fails the whole install. The worker explores + # runs the code from the repo dir (cwd on path), so the project never needs to be # pip-installed as a package. Mirrors the harbor build. poetry install --no-interaction --no-root; \ elif [ -f requirements.txt ]; then pip install -r requirements.txt; \ elif [ -f setup.py ]; then pip install -e .; else true; fi; } ) || return 1 ;; rust) command -v cargo >/dev/null 2>&1 || { printf " ${GRAY}(Rust not in this image; skipping build \xe2\x80\x94 explore-only)${RESET}\n"; touch "$marker"; return 0; } # Build to a container-local target dir (same ENFILE/bind-mount rationale as # node_modules): a Cargo workspace target tree is huge and rebuilds often. ( cd "$dir" \ && { [ -f .env.example ] && cp -n .env.example .env; true; } \ && { [ -n "$bootenv" ] && printf '%s\n' $bootenv >> .env; true; } \ && CARGO_TARGET_DIR="/opt/raccoon-cargo-target/$repo" cargo build --workspace ) || return 1 ;; none|"") : ;; # no-code / explore-only: nothing to install *) printf "${YELLOW}unknown runtime '%s' for %s \xe2\x80\x94 explore-only${RESET}\n" "$runtime" "$repo" ;; esac # Optional one-time app preparation (see setupCmd above), with the member's runtime on # PATH and its bootEnv exported — same environment the app boots with. if [ -n "$setupcmd" ]; then local spath="" case "$kind" in ruby) spath="$RBENV_PATH" ;; node) spath=$(_node_bin "$ver") ;; python) spath="$PYENV_PATH" ;; esac # Output goes to a log, not the worker's terminal: preparation is chatty (an app's # own seed can log hundreds of lines about services it can't reach offline, all of # them harmless), and a wall of red JSON reads as "something is broken". The log # lands in RUN_DIR so `run-app --logs` picks it up like any other. mkdir -p "$RUN_DIR" local slog="$RUN_DIR/setup-$repo.log" printf " ${GRAY}preparing %s (one-time; details in ${RESET}${GRAY}run-app --logs${RESET}${GRAY})\xe2\x80\xa6${RESET}\n" "$repo" if ( cd "$dir" \ && export PATH="${spath:+$spath:}$PATH" \ && case "$kind" in ruby) export RBENV_VERSION="$ver" ;; python) export PYENV_VERSION="$ver" ;; esac \ && { for kv in $bootenv; do export "$kv"; done; } \ && eval "$setupcmd" ) > "$slog" 2>&1; then printf " ${GRAY}\xe2\x9c\x93 %s prepared${RESET}\n" "$repo" else printf " ${YELLOW}setup for %s did not finish cleanly \xe2\x80\x94 the repo is still explorable.${RESET}\n" "$repo" printf " ${GRAY}what went wrong: %s${RESET}\n" "$slog" fi fi touch "$marker" } start_poly() { local repo="${1:-}"; [ -z "$repo" ] && repo="$(_poly_default)" if ! _poly_repos | grep -qx "$repo"; then printf "${RED}unknown repo '%s'.${RESET} available: ${GRAY}%s${RESET}\n" "$repo" "$(_poly_repos | tr '\n' ' ')" return 1 fi local dir="/workspace/repos/$repo" runtime kind ver startcmd bootenv runtime=$(_poly_field "$repo" runtime); kind=${runtime%%:*}; ver=${runtime#*:} startcmd=$(_poly_field "$repo" startCmd) bootenv=$(_poly_field "$repo" bootEnv) # dummy class-load vars (e.g. IVR_UN); see setup_repo # Explore-only members (no-code repos, or no runtime): nothing to boot. if [ "$kind" = "none" ] || [ -z "$kind" ]; then printf " ${CYAN}%s${RESET} is explore-only (no app to run). Read it under ${GRAY}/workspace/repos/%s${RESET}.\n" "$repo" "$repo" return 0 fi # Runtime not in this image (EOL Ruby 2.6.6 / Python 3.7 / an uninstalled node major): # explorable, not runnable here. Python counts as present when EITHER pyenv has the # version or uv can provide it (uv-python images ship no pyenv at all — without the # _py_uv_ok check this gate refused every python member before the uv setup arm ran). if { [ "$kind" = ruby ] && ! _rb_have "$ver"; } \ || { [ "$kind" = python ] && ! _py_have "$ver" && ! _py_uv_ok "$ver"; } \ || { [ "$kind" = node ] && [ -z "$(_node_bin "$ver")" ]; }; then printf " ${YELLOW}%s needs %s, which isn't in this image.${RESET}\n" "$repo" "$runtime" printf " Explore the code under ${GRAY}/workspace/repos/%s${RESET}; to RUN it use that repo's dedicated toolkit.\n" "$repo" return 0 fi local running=0; for pf in "$RUN_DIR"/*.pid; do [ -e "$pf" ] && _alive "$pf" && running=1; done if [ "$running" = 1 ]; then printf "${GRAY}An app is already running.${RESET} Stop it first: ${GRAY}run-app --stop${RESET} (then ${GRAY}run-app %s${RESET}).\n" "$repo" return 0 fi bash /workspace/.devcontainer/post-start.sh >/dev/null 2>&1 || true setup_repo "$repo" || { printf "${RED}setup failed for %s${RESET} \xe2\x80\x94 ${GRAY}run-app --logs${RESET}\n" "$repo"; return 1; } local cmd="" case "$kind" in ruby) if [ -n "$startcmd" ]; then cmd="env $bootenv PATH=$RBENV_PATH:\$PATH RBENV_VERSION=$ver $startcmd" elif [ -f "$dir/bin/rails" ]; then cmd="env $bootenv PATH=$RBENV_PATH:\$PATH RBENV_VERSION=$ver bundle exec rails server -b 0.0.0.0 -p 3000" elif [ -f "$dir/config.ru" ]; then # Rack app that isn't Rails (no bin/rails) — boot via rackup. cmd="env $bootenv PATH=$RBENV_PATH:\$PATH RBENV_VERSION=$ver bundle exec rackup -o 0.0.0.0 -p 3000" else printf " ${GRAY}%s isn't a web app (no bin/rails/config.ru) \xe2\x80\x94 run its tests directly (${RESET}${GRAY}bundle exec rspec${RESET}${GRAY}).${RESET}\n" "$repo" return 0 fi ;; node) local nbin sc="" nbin=$(_node_bin "$ver") if [ -n "$startcmd" ]; then cmd="env $bootenv PATH=$nbin:\$PATH PORT=3000 BROWSER=none HOST=0.0.0.0 $startcmd" elif [ -f "$dir/metro.config.js" ] || [ -d "$dir/ios" ] || [ -d "$dir/android" ]; then # React Native app: no web server in a Linux container; tests still run. printf " ${GRAY}%s is a React Native app (no web server here) \xe2\x80\x94 run its Jest tests directly (${RESET}${GRAY}yarn test${RESET}${GRAY}).${RESET}\n" "$repo" return 0 else # CRA / generic: first dev-server script the repo defines, bound to :3000. local s for s in start dev develop serve; do if node -e "process.exit((((require('$dir/package.json')||{}).scripts)||{})['$s']?0:1)" 2>/dev/null; then sc="$s"; break; fi done if [ -z "$sc" ]; then printf " ${GRAY}%s: deps installed, no dev-server script \xe2\x80\x94 run its tests directly (${RESET}${GRAY}yarn test${RESET}${GRAY}).${RESET}\n" "$repo" return 0 fi # A Create-React-App dev server (react-scripts / react-app-rewired) needs extra env # to survive in this non-interactive container. We spawn it with stdout redirected # to a log, so react-scripts sees a non-TTY and (start.js) registers a stdin-"end" # handler that closes the dev server the moment stdin ends — which it does at once # when there's no interactive terminal, so the app appears to "crash on boot". The # guard is `if (isInteractive || process.env.CI !== 'true')`, so CI=true is what # skips it and keeps the server up. CI=true does NOT make `start` treat warnings as # errors — that is `build` only (verified against react-scripts 3.4.1). The others: # DANGEROUSLY_DISABLE_HOST_CHECK=true let the dev server answer requests arriving # via the published host port (belt-and-braces; # wds3 already allows IP/localhost hosts). # NODE_OPTIONS=--openssl-legacy-provider webpack-4-era CRA crashes on Node 17+ # without it; the flag only EXISTS on Node 17+, # so gate it on the major — older nodes (e.g. # Node 16) abort on "bad option". # Non-CRA dev servers (Next.js, vite, …) don't match the test, so they boot unchanged. local craenv="" if node -e "const s=(((require('$dir/package.json')||{}).scripts)||{})['$sc']||'';process.exit(/react-scripts|react-app-rewired/.test(s)?0:1)" 2>/dev/null; then craenv="CI=true DANGEROUSLY_DISABLE_HOST_CHECK=true" case "${ver%%.*}" in 1[7-9]|[2-9][0-9]) craenv="NODE_OPTIONS=--openssl-legacy-provider $craenv" ;; esac fi cmd="env $bootenv $craenv PATH=$nbin:\$PATH PORT=3000 BROWSER=none HOST=0.0.0.0 yarn $sc" fi ;; python) if [ -z "$startcmd" ]; then printf " ${GRAY}%s: Python deps installed. No web server is wired \xe2\x80\x94 run its tests/scripts directly (e.g. pytest).${RESET}\n" "$repo" return 0 fi if _py_uv_ok "$ver"; then local vdir; vdir=$(_uv_venv_dir "$repo") cmd="env $bootenv VIRTUAL_ENV=$vdir PATH=$vdir/bin:\$PATH $startcmd" else cmd="env $bootenv PATH=$PYENV_PATH:\$PATH PYENV_VERSION=$ver $startcmd" fi ;; rust) if [ -z "$startcmd" ]; then printf " ${GRAY}%s: workspace built. No web server is wired \xe2\x80\x94 run its tests directly (${RESET}${GRAY}cargo test${RESET}${GRAY}).${RESET}\n" "$repo" return 0 fi cmd="env $bootenv CARGO_TARGET_DIR=/opt/raccoon-cargo-target/$repo $startcmd" ;; *) printf "${YELLOW}runtime '%s' for %s isn't runnable here \xe2\x80\x94 explore-only.${RESET}\n" "$runtime" "$repo"; return 0 ;; esac printf " ${CYAN}\xe2\x96\xb6${RESET} starting %s (%s)\xe2\x80\xa6\n" "$repo" "$runtime" _spawn app "$dir" "$cmd" if _wait_tcp 3000; then printf " ${CYAN}\xe2\x9c\x85 %s is up${RESET} open ${CYAN}http://localhost:%s${RESET}\n" "$repo" "$CLIENT_HOST_PORT" # Per-member "how do I actually get in" notes. Only members whose landing page needs # more than the URL need an entry here (e.g. an app whose real sign-in is a hosted # third-party login that can't be reached offline). case "$repo" in strongsuit-app) printf " ${GRAY}Sign-in normally goes through a hosted Auth0 page, which isn't reachable\n" printf " offline, so this app ships a local-only dev-login route. Open\n" printf " ${RESET}${CYAN}http://localhost:%s/dev-login${RESET}${GRAY} to sign in as a seeded admin\n" "$CLIENT_HOST_PORT" printf " (${RESET}${GRAY}?role=MSS${RESET}${GRAY} or ${RESET}${GRAY}?role=MEMBER${RESET}${GRAY} for the other roles). The DB was seeded during setup.${RESET}\n" ;; esac else printf " ${RED}\xe2\x9a\xa0 %s didn't come up in time${RESET} \xe2\x80\x94 ${GRAY}run-app --logs${RESET}\n" "$repo" fi printf " stop ${GRAY}run-app --stop${RESET} switch ${GRAY}run-app --stop && run-app ${RESET}\n" printf " focus ${GRAY}cd /workspace/repos/%s && claude${RESET} (so Claude works in this repo without being told the path)\n" "$repo" } # Generic Rails boot for the standard-shape apps (the rubyforgood repos): a single # `bin/rails server` on container :3000, no separate client. The DB is seeded during # post-create (none of these expose a working self-service signup), so the caller # passes the demo login to print. Optional $2 is a one-line note printed above the # login (e.g. a subdomain caveat). # start_rails [url-note] start_rails() { local login_hint="${1:-}" url_note="${2:-}" _spawn app /workspace/repo "bin/rails server -b 0.0.0.0 -p 3000" printf " ${YELLOW}\xe2\x96\xb6${RESET} starting Rails (puma)\xe2\x80\xa6\n" printf " ${GRAY}\xe2\x8f\xb3 waiting for the app to come up\xe2\x80\xa6${RESET}\n" if _wait_tcp 3000; then printf " ${YELLOW}\xe2\x9c\x85 app is up${RESET}\n" printf " open ${YELLOW}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" [ -n "$url_note" ] && printf " ${GRAY}%s${RESET}\n" "$url_note" [ -n "$login_hint" ] && printf " login ${GRAY}%s${RESET}\n" "$login_hint" else printf " ${RED}\xe2\x9a\xa0 the app didn't come up in time${RESET}\n" printf " check the logs: ${GRAY}run-app --logs${RESET}\n" fi printf " logs ${GRAY}%s/app.log${RESET}\n" "$RUN_DIR" printf " stop ${GRAY}run-app --stop${RESET}\n" } start_app() { # Already running? Don't double-start. local running=0 for pf in "$RUN_DIR"/*.pid; do [ -e "$pf" ] && _alive "$pf" && running=1; done if [ "$running" = 1 ]; then printf "${GRAY}The app is already running.${RESET} Use ${GRAY}run-app --restart${RESET} to restart, ${GRAY}run-app --status${RESET} to check.\n" printf " open ${CYAN}http://localhost:%s${RESET}\n" "$CLIENT_HOST_PORT" return 0 fi # Make sure the database is up before the server tries to connect. bash /workspace/.devcontainer/post-start.sh >/dev/null 2>&1 || true case "$REPO_NAME" in Palolo-031) start_palolo ;; ZenBill-006) start_zenbill ;; zeta-heimdall) start_zeta_heimdall ;; zeta-platform) start_zeta_platform ;; human-essentials) start_rails "test@example.com / password! (sign in at /users/sign_in)" ;; endsideout) start_rails "admin@example.com / password (sign in at /session/new)" ;; community-foundation) # Multi-tenant: the org is a subdomain, so plain localhost only shows the # apex landing page. The seed creates the 'arlington' tenant. start_rails "owner@example.com / password" \ "this app routes by subdomain — open http://arlington.lvh.me:${CLIENT_HOST_PORT}/ (plain localhost shows only the landing page)" ;; stocks-in-the-future) start_rails "username admin / password (sign in at /users/sign_in — login is by USERNAME, not email)" ;; casa) start_rails "casa_admin1@example.com / 12345678 (sign in at /users/sign_in)" ;; awbw) start_rails "umberto.user@example.com / password (sign in at /users/sign_in)" ;; flaredown) start_flaredown ;; alongwithyou) # Fresh scaffold: no routes/auth yet, so plain localhost shows the default Rails # welcome page. No login to print. The app grows over time. start_rails "" "young app — no routes defined yet, so this shows the default Rails welcome page" ;; breezy-complete) start_breezy_complete ;; *) printf "${YELLOW}run-app isn't configured for repo '%s'.${RESET}\n" "${REPO_NAME:-unknown}" printf "Start the app with the project's own dev command from ${GRAY}/workspace/repo${RESET}.\n" return 1 ;; esac } usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' } if _is_polyglot; then # `run-app [] [--restart|--stop|--logs|--status]` — order-independent: the repo # name and the action can appear in either order (e.g. `run-app --restart zeta-hook`), # and the bare verbs (start/restart/stop/...) are recognized as actions, not repos. poly_repo=""; poly_action="start" for a in "$@"; do case "$a" in start) poly_action="start" ;; --restart|restart) poly_action="restart" ;; --stop|stop) poly_action="stop" ;; --logs|logs) poly_action="logs" ;; --status|status) poly_action="status" ;; -h|--help|help) poly_action="help" ;; -*) printf "${RED}Unknown option:${RESET} %s\n\n" "$a"; usage; exit 2 ;; *) poly_repo="$a" ;; esac done case "$poly_action" in start) start_poly "$poly_repo" ;; restart) stop_app; start_poly "$poly_repo" ;; stop) stop_app ;; logs) logs_app ;; status) status_app ;; help) usage ;; esac exit $? fi case "${1:-}" in ""|start) start_app ;; --restart|restart) stop_app; start_app ;; --stop|stop) stop_app ;; --logs|logs) logs_app ;; --status|status) status_app ;; -h|--help|help) usage ;; *) printf "${RED}Unknown option:${RESET} %s\n\n" "$1"; usage; exit 2 ;; esac