A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: improve README and add optional installer script

jack 703f9d24 e973127b

+637 -78
+223 -78
README.md
··· 1 - # Tweets-2-Bsky 1 + # tweets-2-bsky 2 2 3 - Crosspost Tweets/X posts to Bluesky with support for threads, media, and multiple account mappings. 3 + Crosspost posts from Twitter/X to Bluesky with thread support, media handling, account mapping, and a web dashboard. 4 4 5 - ## Features 5 + ## Quick Start (Easy Mode) 6 + 7 + If you are comfortable with terminal basics but do not want to manage PM2 manually, use the installer script. 8 + 9 + ### 1) Clone the repo 6 10 7 - - Automated crossposting from Twitter/X to Bluesky 8 - - Thread-aware posting 9 - - Video/GIF and high-quality image handling 10 - - Multi-source account mappings per Bluesky target 11 - - React + Vite web dashboard (auto light/dark mode) 12 - - Native-styled "Already Posted" feed in dashboard 13 - - Full CLI workflows for CLI-only/cronjob usage 11 + ```bash 12 + git clone https://github.com/j4ckxyz/tweets-2-bsky 13 + cd tweets-2-bsky 14 + ``` 15 + 16 + ### 2) Run install + background start 17 + 18 + ```bash 19 + chmod +x install.sh 20 + ./install.sh 21 + ``` 22 + 23 + What this does by default: 24 + 25 + - installs dependencies 26 + - builds server + web dashboard 27 + - creates/updates `.env` with sensible defaults (`PORT=3000`, generated `JWT_SECRET` if missing) 28 + - starts in the background 29 + - uses PM2 if installed 30 + - otherwise uses `nohup` 31 + - prints your local web URL (for example `http://localhost:3000`) 32 + 33 + ### 3) Open the dashboard 34 + 35 + Open the printed URL in your browser, then: 36 + 37 + 1. Register the first user (this user becomes admin). 38 + 2. Add Twitter cookies in Settings. 39 + 3. Add at least one mapping. 40 + 4. Click `Run now`. 41 + 42 + ### Useful installer commands 43 + 44 + ```bash 45 + ./install.sh --no-start 46 + ./install.sh --start-only 47 + ./install.sh --stop 48 + ./install.sh --status 49 + ./install.sh --port 3100 50 + ``` 51 + 52 + If you prefer full manual setup, skip to [Manual Setup](#manual-setup-technical). 53 + 54 + ## What This Project Does 55 + 56 + - crossposts tweets and threads to Bluesky 57 + - handles images, videos, GIFs, quote tweets, and link cards 58 + - stores processed history in SQLite to avoid reposting 59 + - supports multiple Twitter source usernames per Bluesky target 60 + - provides both: 61 + - web dashboard workflows 62 + - CLI workflows (including cron-friendly mode) 14 63 15 64 ## Requirements 16 65 17 66 - Node.js 22+ 18 67 - npm 19 - - Git 68 + - git 69 + 70 + Optional but recommended: 71 + 72 + - PM2 (for managed background runtime) 73 + - Chrome/Chromium (used for some quote-tweet screenshot fallbacks) 74 + - build tools for native modules (`better-sqlite3`) if your platform needs source compilation 75 + 76 + ## Manual Setup (Technical) 20 77 21 - ## Fast Setup (Web + CLI) 78 + ### Standard run (foreground) 22 79 23 80 ```bash 24 81 git clone https://github.com/j4ckxyz/tweets-2-bsky ··· 30 87 31 88 Open: [http://localhost:3000](http://localhost:3000) 32 89 33 - Notes: 34 - - `npm install` automatically rebuilds native modules (including `better-sqlite3`) for your active Node version. 35 - - If you switch Node versions later, run `npm run rebuild:native`. 90 + ### Set environment values explicitly 91 + 92 + ```bash 93 + cat > .env <<'EOF' 94 + PORT=3000 95 + JWT_SECRET=replace-with-a-strong-random-secret 96 + EOF 97 + ``` 98 + 99 + ### Rebuild native modules after Node version changes 100 + 101 + ```bash 102 + npm run rebuild:native 103 + npm run build 104 + ``` 105 + 106 + ## First-Time Setup via CLI (Alternative to Web Forms) 107 + 108 + ```bash 109 + npm run cli -- setup-twitter 110 + npm run cli -- add-mapping 111 + npm run cli -- run-now 112 + ``` 113 + 114 + ## Recommended Command Examples 115 + 116 + Always invoke CLI commands as: 117 + 118 + ```bash 119 + npm run cli -- <command> 120 + ``` 121 + 122 + ### Status and basic operations 123 + 124 + ```bash 125 + npm run cli -- status 126 + npm run cli -- list 127 + npm run cli -- recent-activity --limit 20 128 + ``` 129 + 130 + ### Credentials and configuration 131 + 132 + ```bash 133 + npm run cli -- setup-twitter 134 + npm run cli -- setup-ai 135 + npm run cli -- set-interval 5 136 + ``` 137 + 138 + ### Mapping management 36 139 37 - ## CLI-Only Setup 140 + ```bash 141 + npm run cli -- add-mapping 142 + npm run cli -- edit-mapping <mapping-id-or-handle> 143 + npm run cli -- remove <mapping-id-or-handle> 144 + ``` 38 145 39 - 1. Configure Twitter cookies: 40 - ```bash 41 - npm run cli -- setup-twitter 42 - ``` 43 - 2. Add mapping(s): 44 - ```bash 45 - npm run cli -- add-mapping 46 - ``` 47 - 3. Run one sync cycle now: 48 - ```bash 49 - npm run cli -- run-now 50 - ``` 146 + ### Running syncs 51 147 52 - ## Updating 148 + ```bash 149 + npm run cli -- run-now 150 + npm run cli -- run-now --dry-run 151 + npm run cli -- run-now --web 152 + ``` 53 153 54 - Use: 154 + ### Backfill and history import 55 155 56 156 ```bash 57 - ./update.sh 157 + npm run cli -- backfill <mapping-id-or-handle> --limit 50 158 + npm run cli -- import-history <mapping-id-or-handle> --limit 100 159 + npm run cli -- clear-cache <mapping-id-or-handle> 58 160 ``` 59 161 60 - What it does: 61 - - pulls latest code 62 - - installs dependencies 63 - - rebuilds native modules 64 - - builds server + web UI 65 - - restarts PM2 process (if PM2 is installed) 66 - - preserves local `config.json` via backup/restore 162 + ### Dangerous operation (admin workflow) 67 163 68 - ## CLI Commands (Feature Parity) 164 + ```bash 165 + npm run cli -- delete-all-posts <mapping-id-or-handle> 166 + ``` 69 167 70 - Always use: 168 + ### Config export/import 71 169 72 170 ```bash 73 - npm run cli -- <command> 171 + npm run cli -- config-export ./tweets-2-bsky-config.json 172 + npm run cli -- config-import ./tweets-2-bsky-config.json 74 173 ``` 75 174 76 - Core commands: 77 - - `setup-twitter`: Configure primary + backup Twitter cookies 78 - - `setup-ai`: Configure AI provider/API settings 79 - - `add-mapping`, `edit-mapping`, `remove`, `list` 80 - - `set-interval <minutes>`: Scheduler interval 81 - - `run-now [--dry-run] [--web]`: Run one cycle immediately (good for cron) 82 - - `backfill [mapping] --limit 15 [--dry-run] [--web]` 83 - - `import-history [mapping] --limit 15 [--dry-run] [--web]` 84 - - `clear-cache [mapping]` 85 - - `delete-all-posts [mapping]` 86 - - `recent-activity --limit 20` 87 - - `config-export [file]` 88 - - `config-import <file>` 89 - - `status` 175 + Mapping references accept: 90 176 91 - Mapping arguments can be mapping ID, Bluesky handle, or Twitter username. 177 + - mapping ID 178 + - Bluesky handle/identifier 179 + - Twitter username 92 180 93 - ## Cronjob Example 181 + ## Cron / CLI-Only Operation 94 182 95 183 Run every 5 minutes: 96 184 ··· 98 186 */5 * * * * cd /path/to/tweets-2-bsky && /usr/bin/npm run cli -- run-now >> /tmp/tweets-2-bsky.log 2>&1 99 187 ``` 100 188 101 - Backfill specific mapping once: 189 + Run one backfill once: 102 190 103 191 ```bash 104 192 npm run cli -- backfill <mapping-id-or-handle> --limit 50 105 193 ``` 106 194 107 - ## Web Dashboard 195 + ## Background Runtime Options 108 196 109 - 1. Register first user (becomes admin) 110 - 2. Configure Twitter + AI settings 111 - 3. Add mappings 112 - 4. Use: 113 - - `Run now` 114 - - backfill/reset actions per mapping 115 - - config export/import 116 - - "Already Posted" feed for native-themed post browsing 197 + ### Option A: use `install.sh` (recommended) 117 198 118 - ## Configuration & Security 199 + ```bash 200 + ./install.sh 201 + ./install.sh --status 202 + ./install.sh --stop 203 + ``` 119 204 120 - ### Environment variables 205 + ### Option B: manage PM2 directly 121 206 122 - Create `.env` (recommended): 207 + ```bash 208 + pm2 start dist/index.js --name tweets-2-bsky 209 + pm2 logs tweets-2-bsky 210 + pm2 restart tweets-2-bsky --update-env 211 + pm2 save 212 + ``` 123 213 124 - ```env 125 - PORT=3000 126 - JWT_SECRET=your-super-secret-key-change-this 214 + ### Option C: no PM2 (nohup) 215 + 216 + ```bash 217 + mkdir -p data/runtime 218 + nohup npm start > data/runtime/tweets-2-bsky.log 2>&1 & 219 + echo $! > data/runtime/tweets-2-bsky.pid 220 + ``` 221 + 222 + Stop nohup process: 223 + 224 + ```bash 225 + kill "$(cat data/runtime/tweets-2-bsky.pid)" 226 + ``` 227 + 228 + ## Updating 229 + 230 + Use: 231 + 232 + ```bash 233 + ./update.sh 234 + ``` 235 + 236 + `update.sh`: 237 + 238 + - pulls latest code 239 + - installs dependencies 240 + - rebuilds native modules 241 + - builds server + web dashboard 242 + - restarts PM2 process when PM2 is available 243 + - preserves local `config.json` with backup/restore 244 + 245 + ## Data, Config, and Security 246 + 247 + Local files: 248 + 249 + - `config.json`: mappings, credentials, users, app settings (sensitive; do not share) 250 + - `data/database.sqlite`: processed tweet history and metadata 251 + - `.env`: runtime environment variables (`PORT`, `JWT_SECRET`, optional overrides) 252 + 253 + Security notes: 254 + 255 + - first registered dashboard user is admin 256 + - if `JWT_SECRET` is missing, server falls back to an insecure default; set your own secret in `.env` 257 + - prefer Bluesky app passwords (not your full account password) 258 + 259 + ## Development 260 + 261 + ### Start backend/scheduler from source 262 + 263 + ```bash 264 + npm run dev 127 265 ``` 128 266 129 - If `JWT_SECRET` is not set, a fallback secret is used. 267 + ### Start Vite web dev server 130 268 131 - ### Local data files 269 + ```bash 270 + npm run dev:web 271 + ``` 132 272 133 - - `config.json`: mappings + auth settings + web users (do not share) 134 - - `data/database.sqlite`: processed tweet history 273 + ### Build and quality checks 274 + 275 + ```bash 276 + npm run build 277 + npm run typecheck 278 + npm run lint 279 + ``` 135 280 136 281 ## Troubleshooting 137 282 138 283 See: `TROUBLESHOOTING.md` 139 284 140 - Most common fix after changing Node versions: 285 + Common recovery after changing Node versions: 141 286 142 287 ```bash 143 288 npm run rebuild:native
+414
install.sh
··· 1 + #!/usr/bin/env bash 2 + 3 + set -euo pipefail 4 + 5 + APP_NAME="tweets-2-bsky" 6 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 + cd "$SCRIPT_DIR" 8 + 9 + ENV_FILE="$SCRIPT_DIR/.env" 10 + RUNTIME_DIR="$SCRIPT_DIR/data/runtime" 11 + PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid" 12 + LOG_FILE="$RUNTIME_DIR/${APP_NAME}.log" 13 + 14 + ACTION="install" 15 + DO_INSTALL=1 16 + DO_BUILD=1 17 + DO_START=1 18 + RUNNER="auto" 19 + PORT_OVERRIDE="" 20 + APP_PORT="" 21 + ACTIVE_RUNNER="" 22 + CREATED_JWT_SECRET=0 23 + 24 + usage() { 25 + cat <<'USAGE' 26 + Usage: ./install.sh [options] 27 + 28 + Default behavior: 29 + - Installs dependencies 30 + - Builds server + web app 31 + - Starts in the background (PM2 if installed, otherwise nohup) 32 + - Prints local web URL 33 + 34 + Options: 35 + --no-start Install/build only (do not start background process) 36 + --start-only Start background process only (skip install/build) 37 + --stop Stop background process (PM2 and/or nohup) 38 + --status Show background process status 39 + --pm2 Force PM2 runner 40 + --nohup Force nohup runner 41 + --port <number> Set or override PORT in .env 42 + --skip-install Skip npm install 43 + --skip-build Skip npm run build 44 + -h, --help Show this help 45 + USAGE 46 + } 47 + 48 + require_command() { 49 + local command_name="$1" 50 + if ! command -v "$command_name" >/dev/null 2>&1; then 51 + echo "Required command not found: $command_name" 52 + exit 1 53 + fi 54 + } 55 + 56 + is_valid_port() { 57 + local candidate="$1" 58 + [[ "$candidate" =~ ^[0-9]+$ ]] || return 1 59 + (( candidate >= 1 && candidate <= 65535 )) 60 + } 61 + 62 + get_env_value() { 63 + local key="$1" 64 + if [[ ! -f "$ENV_FILE" ]]; then 65 + return 0 66 + fi 67 + local line 68 + line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)" 69 + if [[ -z "$line" ]]; then 70 + return 0 71 + fi 72 + printf '%s\n' "${line#*=}" 73 + } 74 + 75 + upsert_env_value() { 76 + local key="$1" 77 + local value="$2" 78 + touch "$ENV_FILE" 79 + local tmp_file 80 + tmp_file="$(mktemp)" 81 + awk -v key="$key" -v value="$value" ' 82 + BEGIN { updated = 0 } 83 + $0 ~ ("^" key "=") { 84 + print key "=" value 85 + updated = 1 86 + next 87 + } 88 + { print } 89 + END { 90 + if (!updated) { 91 + print key "=" value 92 + } 93 + } 94 + ' "$ENV_FILE" > "$tmp_file" 95 + mv "$tmp_file" "$ENV_FILE" 96 + } 97 + 98 + ensure_env_defaults() { 99 + local existing_port 100 + existing_port="$(get_env_value PORT)" 101 + if [[ -n "$PORT_OVERRIDE" ]]; then 102 + APP_PORT="$PORT_OVERRIDE" 103 + elif [[ -n "$existing_port" ]]; then 104 + APP_PORT="$existing_port" 105 + else 106 + APP_PORT="3000" 107 + fi 108 + 109 + if ! is_valid_port "$APP_PORT"; then 110 + echo "Invalid port: $APP_PORT" 111 + exit 1 112 + fi 113 + 114 + if [[ -z "$existing_port" || -n "$PORT_OVERRIDE" ]]; then 115 + upsert_env_value PORT "$APP_PORT" 116 + fi 117 + 118 + local existing_secret 119 + existing_secret="$(get_env_value JWT_SECRET)" 120 + if [[ -z "$existing_secret" ]]; then 121 + local generated_secret 122 + generated_secret="$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")" 123 + upsert_env_value JWT_SECRET "$generated_secret" 124 + CREATED_JWT_SECRET=1 125 + fi 126 + } 127 + 128 + ensure_build_artifacts() { 129 + if [[ ! -f "$SCRIPT_DIR/dist/index.js" ]]; then 130 + echo "Build output not found (dist/index.js). Running build now." 131 + npm run build 132 + fi 133 + } 134 + 135 + install_and_build() { 136 + if [[ "$DO_INSTALL" -eq 1 ]]; then 137 + echo "Installing dependencies" 138 + npm install 139 + fi 140 + 141 + if [[ "$DO_BUILD" -eq 1 ]]; then 142 + echo "Building server and web app" 143 + npm run build 144 + fi 145 + } 146 + 147 + stop_nohup_if_running() { 148 + if [[ ! -f "$PID_FILE" ]]; then 149 + return 1 150 + fi 151 + 152 + local pid 153 + pid="$(cat "$PID_FILE" 2>/dev/null || true)" 154 + if [[ -z "$pid" ]]; then 155 + rm -f "$PID_FILE" 156 + return 1 157 + fi 158 + 159 + if kill -0 "$pid" >/dev/null 2>&1; then 160 + kill "$pid" >/dev/null 2>&1 || true 161 + rm -f "$PID_FILE" 162 + return 0 163 + fi 164 + 165 + rm -f "$PID_FILE" 166 + return 1 167 + } 168 + 169 + start_with_nohup() { 170 + mkdir -p "$RUNTIME_DIR" 171 + stop_nohup_if_running >/dev/null 2>&1 || true 172 + 173 + echo "Starting with nohup" 174 + nohup npm start > "$LOG_FILE" 2>&1 & 175 + echo "$!" > "$PID_FILE" 176 + 177 + local pid 178 + pid="$(cat "$PID_FILE")" 179 + sleep 1 180 + if ! kill -0 "$pid" >/dev/null 2>&1; then 181 + echo "Failed to start background process with nohup." 182 + echo "Check logs: $LOG_FILE" 183 + exit 1 184 + fi 185 + } 186 + 187 + stop_pm2_if_running() { 188 + if ! command -v pm2 >/dev/null 2>&1; then 189 + return 1 190 + fi 191 + 192 + if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 193 + pm2 delete "$APP_NAME" >/dev/null 2>&1 || true 194 + pm2 save >/dev/null 2>&1 || true 195 + return 0 196 + fi 197 + 198 + return 1 199 + } 200 + 201 + start_with_pm2() { 202 + echo "Starting with PM2" 203 + 204 + if pm2 describe "twitter-mirror" >/dev/null 2>&1; then 205 + pm2 delete "twitter-mirror" >/dev/null 2>&1 || true 206 + fi 207 + 208 + if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 209 + pm2 restart "$APP_NAME" --update-env >/dev/null 2>&1 210 + else 211 + pm2 start dist/index.js --name "$APP_NAME" --update-env >/dev/null 2>&1 212 + fi 213 + pm2 save >/dev/null 2>&1 || true 214 + } 215 + 216 + start_background() { 217 + local resolved_runner="$RUNNER" 218 + if [[ "$resolved_runner" == "auto" ]]; then 219 + if command -v pm2 >/dev/null 2>&1; then 220 + resolved_runner="pm2" 221 + else 222 + resolved_runner="nohup" 223 + fi 224 + fi 225 + 226 + case "$resolved_runner" in 227 + pm2) 228 + require_command pm2 229 + start_with_pm2 230 + ACTIVE_RUNNER="pm2" 231 + ;; 232 + nohup) 233 + start_with_nohup 234 + ACTIVE_RUNNER="nohup" 235 + ;; 236 + *) 237 + echo "Unsupported runner: $resolved_runner" 238 + exit 1 239 + ;; 240 + esac 241 + } 242 + 243 + wait_for_web() { 244 + if ! command -v curl >/dev/null 2>&1; then 245 + return 0 246 + fi 247 + 248 + local url="http://127.0.0.1:${APP_PORT}" 249 + local attempt 250 + for ((attempt = 1; attempt <= 30; attempt++)); do 251 + if curl -fsS "$url" >/dev/null 2>&1; then 252 + return 0 253 + fi 254 + sleep 1 255 + done 256 + 257 + return 1 258 + } 259 + 260 + print_access_info() { 261 + echo "" 262 + echo "Setup complete." 263 + echo "Web app URL: http://localhost:${APP_PORT}" 264 + 265 + if [[ "$CREATED_JWT_SECRET" -eq 1 ]]; then 266 + echo "Generated JWT_SECRET in .env" 267 + fi 268 + 269 + if [[ "$ACTIVE_RUNNER" == "pm2" ]]; then 270 + echo "Process manager: PM2" 271 + echo "Status: pm2 status $APP_NAME" 272 + echo "Logs: pm2 logs $APP_NAME" 273 + echo "Stop: pm2 delete $APP_NAME" 274 + elif [[ "$ACTIVE_RUNNER" == "nohup" ]]; then 275 + echo "Process manager: nohup" 276 + echo "PID file: $PID_FILE" 277 + echo "Logs: tail -f $LOG_FILE" 278 + echo "Stop: ./install.sh --stop" 279 + fi 280 + 281 + if wait_for_web; then 282 + echo "Health check: OK" 283 + else 284 + echo "Health check: not ready yet (service may still be starting)" 285 + fi 286 + } 287 + 288 + show_status() { 289 + local found=0 290 + local configured_port 291 + configured_port="$(get_env_value PORT)" 292 + if [[ -n "$configured_port" ]]; then 293 + echo "Configured PORT: $configured_port" 294 + fi 295 + 296 + if command -v pm2 >/dev/null 2>&1 && pm2 describe "$APP_NAME" >/dev/null 2>&1; then 297 + found=1 298 + echo "PM2 process is running:" 299 + pm2 status "$APP_NAME" 300 + fi 301 + 302 + if [[ -f "$PID_FILE" ]]; then 303 + local pid 304 + pid="$(cat "$PID_FILE" 2>/dev/null || true)" 305 + if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then 306 + found=1 307 + echo "nohup process is running with PID $pid" 308 + echo "Logs: $LOG_FILE" 309 + else 310 + echo "Found stale PID file at $PID_FILE" 311 + fi 312 + fi 313 + 314 + if [[ "$found" -eq 0 ]]; then 315 + echo "No running background process found for $APP_NAME" 316 + fi 317 + } 318 + 319 + stop_all() { 320 + local stopped=0 321 + if stop_pm2_if_running; then 322 + stopped=1 323 + echo "Stopped PM2 process: $APP_NAME" 324 + fi 325 + 326 + if stop_nohup_if_running; then 327 + stopped=1 328 + echo "Stopped nohup process from PID file" 329 + fi 330 + 331 + if [[ "$stopped" -eq 0 ]]; then 332 + echo "No running process found for $APP_NAME" 333 + fi 334 + } 335 + 336 + while [[ $# -gt 0 ]]; do 337 + case "$1" in 338 + --no-start) 339 + DO_START=0 340 + ;; 341 + --start-only) 342 + ACTION="start" 343 + DO_INSTALL=0 344 + DO_BUILD=0 345 + DO_START=1 346 + ;; 347 + --stop) 348 + ACTION="stop" 349 + ;; 350 + --status) 351 + ACTION="status" 352 + ;; 353 + --pm2) 354 + RUNNER="pm2" 355 + ;; 356 + --nohup) 357 + RUNNER="nohup" 358 + ;; 359 + --port) 360 + if [[ $# -lt 2 ]]; then 361 + echo "Missing value for --port" 362 + exit 1 363 + fi 364 + PORT_OVERRIDE="$2" 365 + shift 366 + ;; 367 + --skip-install) 368 + DO_INSTALL=0 369 + ;; 370 + --skip-build) 371 + DO_BUILD=0 372 + ;; 373 + -h|--help) 374 + usage 375 + exit 0 376 + ;; 377 + *) 378 + echo "Unknown option: $1" 379 + usage 380 + exit 1 381 + ;; 382 + esac 383 + shift 384 + done 385 + 386 + case "$ACTION" in 387 + stop) 388 + stop_all 389 + exit 0 390 + ;; 391 + status) 392 + show_status 393 + exit 0 394 + ;; 395 + esac 396 + 397 + require_command node 398 + require_command npm 399 + 400 + ensure_env_defaults 401 + 402 + if [[ "$ACTION" == "install" ]]; then 403 + install_and_build 404 + fi 405 + 406 + if [[ "$DO_START" -eq 0 ]]; then 407 + echo "Install/build complete. Start later with: ./install.sh --start-only" 408 + echo "Configured web URL: http://localhost:${APP_PORT}" 409 + exit 0 410 + fi 411 + 412 + ensure_build_artifacts 413 + start_background 414 + print_access_info