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.
1#!/usr/bin/env bash
2
3set -euo pipefail
4
5APP_NAME="tweets-2-bsky"
6LEGACY_APP_NAME="twitter-mirror"
7SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8cd "$SCRIPT_DIR"
9
10ENV_FILE="$SCRIPT_DIR/.env"
11RUNTIME_DIR="$SCRIPT_DIR/data/runtime"
12PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid"
13LOG_FILE="$RUNTIME_DIR/${APP_NAME}.log"
14LOCK_DIR="$RUNTIME_DIR/.install.lock"
15
16ACTION="install"
17DO_INSTALL=1
18DO_BUILD=1
19DO_START=1
20DO_NATIVE_REBUILD=1
21RUNNER="auto"
22PORT_OVERRIDE=""
23HOST_OVERRIDE=""
24APP_PORT=""
25APP_HOST=""
26ACTIVE_RUNNER=""
27CREATED_JWT_SECRET=0
28BUN_BIN=""
29
30usage() {
31 cat <<'USAGE'
32Usage: ./install.sh [options]
33
34Default behavior:
35 - Installs dependencies
36 - Rebuilds native modules if needed
37 - Builds server + web app
38 - Starts in the background (PM2 if installed, otherwise nohup)
39 - Prints local web URL
40
41Options:
42 --no-start Install/build only (do not start background process)
43 --start-only Start background process only (skip install/build)
44 --stop Stop background process (PM2 and/or nohup)
45 --status Show background process status
46 --pm2 Force PM2 runner
47 --nohup Force nohup runner
48 --port <number> Set or override PORT in .env
49 --host <bind-host> Set or override HOST in .env (for example 127.0.0.1)
50 --skip-install Skip bun install
51 --skip-build Skip bun run build
52 --skip-native-rebuild Skip native-module compatibility rebuild checks
53 -h, --help Show this help
54USAGE
55}
56
57require_command() {
58 local command_name="$1"
59 if ! command -v "$command_name" >/dev/null 2>&1; then
60 echo "Required command not found: $command_name"
61 exit 1
62 fi
63}
64
65is_valid_port() {
66 local candidate="$1"
67 [[ "$candidate" =~ ^[0-9]+$ ]] || return 1
68 (( candidate >= 1 && candidate <= 65535 ))
69}
70
71ensure_bun_runtime() {
72 install_latest_bun() {
73 if command -v curl >/dev/null 2>&1; then
74 curl -fsSL https://bun.sh/install | bash >/dev/null
75 return 0
76 fi
77 if command -v wget >/dev/null 2>&1; then
78 wget -qO- https://bun.sh/install | bash >/dev/null
79 return 0
80 fi
81
82 echo "❌ Bun is required, and curl/wget is unavailable for auto-install."
83 echo " Install Bun manually: https://bun.com/docs/installation"
84 exit 1
85 }
86
87 resolve_bun_bin() {
88 if command -v bun >/dev/null 2>&1; then
89 command -v bun
90 return 0
91 fi
92 if [[ -x "${HOME}/.bun/bin/bun" ]]; then
93 printf '%s\n' "${HOME}/.bun/bin/bun"
94 return 0
95 fi
96 return 1
97 }
98
99 if ! BUN_BIN="$(resolve_bun_bin)"; then
100 echo "📦 Bun not found. Installing latest Bun..."
101 install_latest_bun
102 BUN_BIN="$(resolve_bun_bin || true)"
103 fi
104
105 if [[ -z "$BUN_BIN" || ! -x "$BUN_BIN" ]]; then
106 echo "❌ Bun could not be resolved."
107 echo " Install Bun manually: https://bun.com/docs/installation"
108 exit 1
109 fi
110
111 export PATH="$(dirname "$BUN_BIN"):$PATH"
112
113 if ! "$BUN_BIN" upgrade >/dev/null 2>&1; then
114 echo "⚠️ Bun auto-upgrade failed. Reinstalling latest Bun..."
115 install_latest_bun
116 BUN_BIN="$(resolve_bun_bin || true)"
117 fi
118
119 if [[ -z "$BUN_BIN" || ! -x "$BUN_BIN" ]]; then
120 echo "❌ Bun could not be resolved after auto-upgrade."
121 echo " Install Bun manually: https://bun.com/docs/installation"
122 exit 1
123 fi
124
125 export PATH="$(dirname "$BUN_BIN"):$PATH"
126
127 local bun_major
128 bun_major="$($BUN_BIN --version | awk -F. '{print $1}' 2>/dev/null || echo 0)"
129 if [[ "$bun_major" -lt 1 ]]; then
130 echo "❌ Bun 1.x+ is required. Current: $($BUN_BIN --version 2>/dev/null || echo 'unknown')"
131 exit 1
132 fi
133}
134
135run_bun() {
136 "$BUN_BIN" "$@"
137}
138
139acquire_lock() {
140 mkdir -p "$RUNTIME_DIR"
141 if ! mkdir "$LOCK_DIR" 2>/dev/null; then
142 echo "Another install/update operation appears to be running."
143 echo "If this is stale, remove: $LOCK_DIR"
144 exit 1
145 fi
146}
147
148release_lock() {
149 rmdir "$LOCK_DIR" >/dev/null 2>&1 || true
150}
151
152cleanup() {
153 release_lock
154}
155
156get_env_value() {
157 local key="$1"
158 if [[ ! -f "$ENV_FILE" ]]; then
159 return 0
160 fi
161 local line
162 line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
163 if [[ -z "$line" ]]; then
164 return 0
165 fi
166 printf '%s\n' "${line#*=}"
167}
168
169upsert_env_value() {
170 local key="$1"
171 local value="$2"
172 touch "$ENV_FILE"
173 local tmp_file
174 tmp_file="$(mktemp)"
175 awk -v key="$key" -v value="$value" '
176 BEGIN { updated = 0 }
177 $0 ~ ("^" key "=") {
178 print key "=" value
179 updated = 1
180 next
181 }
182 { print }
183 END {
184 if (!updated) {
185 print key "=" value
186 }
187 }
188 ' "$ENV_FILE" > "$tmp_file"
189 mv "$tmp_file" "$ENV_FILE"
190}
191
192ensure_env_defaults() {
193 local existing_port
194 existing_port="$(get_env_value PORT)"
195 if [[ -n "$PORT_OVERRIDE" ]]; then
196 APP_PORT="$PORT_OVERRIDE"
197 elif [[ -n "$existing_port" ]]; then
198 APP_PORT="$existing_port"
199 else
200 APP_PORT="3000"
201 fi
202
203 if ! is_valid_port "$APP_PORT"; then
204 echo "Invalid port: $APP_PORT"
205 exit 1
206 fi
207
208 if [[ -z "$existing_port" || -n "$PORT_OVERRIDE" ]]; then
209 upsert_env_value PORT "$APP_PORT"
210 fi
211
212 local existing_host
213 existing_host="$(get_env_value HOST)"
214 if [[ -n "$HOST_OVERRIDE" ]]; then
215 APP_HOST="$HOST_OVERRIDE"
216 upsert_env_value HOST "$APP_HOST"
217 elif [[ -n "$existing_host" ]]; then
218 APP_HOST="$existing_host"
219 else
220 APP_HOST="0.0.0.0"
221 fi
222
223 local existing_secret
224 existing_secret="$(get_env_value JWT_SECRET)"
225 if [[ -z "$existing_secret" ]]; then
226 local generated_secret
227 generated_secret="$(run_bun -e "console.log(require('crypto').randomBytes(32).toString('hex'))")"
228 upsert_env_value JWT_SECRET "$generated_secret"
229 CREATED_JWT_SECRET=1
230 fi
231}
232
233ensure_node_modules_present() {
234 if [[ ! -d "$SCRIPT_DIR/node_modules" ]]; then
235 echo "Dependencies not found. Run ./install.sh (without --start-only) first."
236 exit 1
237 fi
238}
239
240native_module_compatible() {
241 run_bun -e "try{require('better-sqlite3');process.exit(0)}catch(e){console.error(e && e.message ? e.message : e);process.exit(1)}" >/dev/null 2>&1
242}
243
244run_native_rebuild() {
245 echo "Verifying native modules for Bun $($BUN_BIN --version)..."
246
247 if run_bun run rebuild:native; then
248 return 0
249 fi
250
251 echo "rebuild:native failed. Forcing fresh Bun install..."
252 run_bun install --force
253}
254
255ensure_native_compatibility() {
256 if [[ "$DO_NATIVE_REBUILD" -eq 0 ]]; then
257 return 0
258 fi
259
260 if native_module_compatible; then
261 return 0
262 fi
263
264 echo "Detected native module mismatch (likely from runtime/dependency change)."
265 run_native_rebuild
266
267 if ! native_module_compatible; then
268 echo "Native module validation still failed after rebuild."
269 echo "Try reinstalling dependencies: rm -rf node_modules bun.lock && bun install"
270 exit 1
271 fi
272}
273
274ensure_build_artifacts() {
275 if [[ ! -f "$SCRIPT_DIR/dist/index.js" ]]; then
276 echo "Build output not found (dist/index.js). Running build now."
277 run_bun run build
278 fi
279}
280
281install_and_build() {
282 if [[ "$DO_INSTALL" -eq 1 ]]; then
283 echo "Installing dependencies"
284 run_bun install
285 fi
286
287 ensure_node_modules_present
288 ensure_native_compatibility
289
290 if [[ "$DO_BUILD" -eq 1 ]]; then
291 echo "Building server and web app"
292 run_bun run build
293 fi
294}
295
296pid_looks_like_app() {
297 local pid="$1"
298 local cmd
299 cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
300 [[ "$cmd" == *"dist/index.js"* || "$cmd" == *"bun run start"* || "$cmd" == *"bun dist/index.js"* || "$cmd" == *"$APP_NAME"* ]]
301}
302
303stop_pid_gracefully() {
304 local pid="$1"
305
306 if ! kill -0 "$pid" >/dev/null 2>&1; then
307 return 0
308 fi
309
310 kill "$pid" >/dev/null 2>&1 || true
311
312 local attempt
313 for attempt in $(seq 1 20); do
314 if ! kill -0 "$pid" >/dev/null 2>&1; then
315 return 0
316 fi
317 sleep 0.5
318 done
319
320 kill -9 "$pid" >/dev/null 2>&1 || true
321}
322
323stop_nohup_if_running() {
324 if [[ ! -f "$PID_FILE" ]]; then
325 return 1
326 fi
327
328 local pid
329 pid="$(cat "$PID_FILE" 2>/dev/null || true)"
330 if [[ -z "$pid" ]]; then
331 rm -f "$PID_FILE"
332 return 1
333 fi
334
335 if ! kill -0 "$pid" >/dev/null 2>&1; then
336 rm -f "$PID_FILE"
337 return 1
338 fi
339
340 if ! pid_looks_like_app "$pid"; then
341 echo "PID file points to a non-app process. Removing stale PID file: $PID_FILE"
342 rm -f "$PID_FILE"
343 return 1
344 fi
345
346 stop_pid_gracefully "$pid"
347 rm -f "$PID_FILE"
348 return 0
349}
350
351stop_pm2_if_running() {
352 if ! command -v pm2 >/dev/null 2>&1; then
353 return 1
354 fi
355
356 local stopped=0
357
358 echo "[pm2] Inspecting existing PM2 processes..."
359
360 if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
361 echo "[pm2] Deleting existing process: $APP_NAME"
362 pm2 delete "$APP_NAME" || true
363 stopped=1
364 fi
365
366 if pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then
367 echo "[pm2] Deleting legacy process: $LEGACY_APP_NAME"
368 pm2 delete "$LEGACY_APP_NAME" || true
369 stopped=1
370 fi
371
372 if [[ "$stopped" -eq 1 ]]; then
373 echo "[pm2] Saving PM2 process list"
374 pm2 save || true
375 return 0
376 fi
377
378 return 1
379}
380
381start_with_nohup() {
382 mkdir -p "$RUNTIME_DIR"
383 stop_nohup_if_running >/dev/null 2>&1 || true
384
385 echo "Starting with nohup"
386 nohup "$BUN_BIN" run start >> "$LOG_FILE" 2>&1 &
387 echo "$!" > "$PID_FILE"
388
389 local pid
390 pid="$(cat "$PID_FILE")"
391 sleep 1
392 if ! kill -0 "$pid" >/dev/null 2>&1; then
393 echo "Failed to start background process with nohup."
394 echo "Check logs: $LOG_FILE"
395 tail -n 40 "$LOG_FILE" 2>/dev/null || true
396 exit 1
397 fi
398}
399
400start_with_pm2() {
401 echo "Starting with PM2"
402
403 if pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then
404 echo "[pm2] Removing legacy process before start: $LEGACY_APP_NAME"
405 pm2 delete "$LEGACY_APP_NAME" || true
406 fi
407
408 if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
409 echo "[pm2] Recreating existing process with Bun binary launcher: $APP_NAME"
410 pm2 delete "$APP_NAME" || true
411 else
412 echo "[pm2] Starting new process: $APP_NAME (cwd=$SCRIPT_DIR, script=dist/index.js)"
413 fi
414
415 pm2 start "$BUN_BIN" --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env -- dist/index.js
416
417 echo "[pm2] Saving PM2 process list"
418 pm2 save || true
419}
420
421start_background() {
422 local resolved_runner="$RUNNER"
423 if [[ "$resolved_runner" == "auto" ]]; then
424 if command -v pm2 >/dev/null 2>&1; then
425 resolved_runner="pm2"
426 else
427 resolved_runner="nohup"
428 fi
429 fi
430
431 case "$resolved_runner" in
432 pm2)
433 require_command pm2
434 start_with_pm2
435 ACTIVE_RUNNER="pm2"
436 ;;
437 nohup)
438 start_with_nohup
439 ACTIVE_RUNNER="nohup"
440 ;;
441 *)
442 echo "Unsupported runner: $resolved_runner"
443 exit 1
444 ;;
445 esac
446}
447
448wait_for_web() {
449 local url="http://127.0.0.1:${APP_PORT}"
450 local attempt
451
452 for attempt in $(seq 1 30); do
453 if command -v curl >/dev/null 2>&1; then
454 if curl -fsS "$url" >/dev/null 2>&1; then
455 return 0
456 fi
457 else
458 if run_bun -e "const http=require('http');const req=http.get('$url',res=>{process.exit(res.statusCode && res.statusCode < 500 ? 0 : 1)});req.setTimeout(1500,()=>{req.destroy();process.exit(1)});req.on('error',()=>process.exit(1));" >/dev/null 2>&1; then
459 return 0
460 fi
461 fi
462 sleep 1
463 done
464
465 return 1
466}
467
468print_access_info() {
469 echo ""
470 echo "Setup complete."
471 echo "Bind host: ${APP_HOST}"
472 echo "Web app URL (local): http://localhost:${APP_PORT}"
473
474 if [[ "$CREATED_JWT_SECRET" -eq 1 ]]; then
475 echo "Generated JWT_SECRET in .env"
476 fi
477
478 if [[ "$APP_HOST" == "127.0.0.1" || "$APP_HOST" == "::1" || "$APP_HOST" == "localhost" ]]; then
479 echo "Access scope: local-only bind (use reverse proxy or Tailscale for remote access)"
480 else
481 echo "Access scope: network-accessible bind"
482 fi
483
484 if [[ "$ACTIVE_RUNNER" == "pm2" ]]; then
485 echo "Process manager: PM2"
486 echo "Status: pm2 status $APP_NAME"
487 echo "Logs: pm2 logs $APP_NAME"
488 echo "Stop: ./install.sh --stop"
489 elif [[ "$ACTIVE_RUNNER" == "nohup" ]]; then
490 echo "Process manager: nohup"
491 echo "PID file: $PID_FILE"
492 echo "Logs: tail -f $LOG_FILE"
493 echo "Stop: ./install.sh --stop"
494 fi
495
496 if wait_for_web; then
497 echo "Health check: OK"
498 else
499 echo "Health check: not ready yet (service may still be starting)"
500 fi
501}
502
503show_status() {
504 local found=0
505 local configured_port
506 local configured_host
507 configured_port="$(get_env_value PORT)"
508 configured_host="$(get_env_value HOST)"
509
510 if [[ -n "$configured_port" ]]; then
511 echo "Configured PORT: $configured_port"
512 fi
513 if [[ -n "$configured_host" ]]; then
514 echo "Configured HOST: $configured_host"
515 fi
516
517 if command -v pm2 >/dev/null 2>&1; then
518 if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
519 found=1
520 echo "PM2 process is running: $APP_NAME"
521 pm2 status "$APP_NAME"
522 elif pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then
523 found=1
524 echo "PM2 process is running: $LEGACY_APP_NAME"
525 pm2 status "$LEGACY_APP_NAME"
526 fi
527 fi
528
529 if [[ -f "$PID_FILE" ]]; then
530 local pid
531 pid="$(cat "$PID_FILE" 2>/dev/null || true)"
532 if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then
533 found=1
534 echo "nohup process is running with PID $pid"
535 echo "Logs: $LOG_FILE"
536 else
537 echo "Found stale PID file at $PID_FILE"
538 fi
539 fi
540
541 if [[ "$found" -eq 0 ]]; then
542 echo "No running background process found for $APP_NAME"
543 fi
544}
545
546stop_all() {
547 local stopped=0
548
549 if stop_pm2_if_running; then
550 stopped=1
551 echo "Stopped PM2 process(es)."
552 fi
553
554 if stop_nohup_if_running; then
555 stopped=1
556 echo "Stopped nohup process from PID file"
557 fi
558
559 if [[ "$stopped" -eq 0 ]]; then
560 echo "No running process found for $APP_NAME"
561 fi
562}
563
564while [[ $# -gt 0 ]]; do
565 case "$1" in
566 --no-start)
567 DO_START=0
568 ;;
569 --start-only)
570 ACTION="start"
571 DO_INSTALL=0
572 DO_BUILD=0
573 DO_START=1
574 ;;
575 --stop)
576 ACTION="stop"
577 ;;
578 --status)
579 ACTION="status"
580 ;;
581 --pm2)
582 RUNNER="pm2"
583 ;;
584 --nohup)
585 RUNNER="nohup"
586 ;;
587 --port)
588 if [[ $# -lt 2 ]]; then
589 echo "Missing value for --port"
590 exit 1
591 fi
592 PORT_OVERRIDE="$2"
593 shift
594 ;;
595 --host)
596 if [[ $# -lt 2 ]]; then
597 echo "Missing value for --host"
598 exit 1
599 fi
600 HOST_OVERRIDE="$2"
601 shift
602 ;;
603 --skip-install)
604 DO_INSTALL=0
605 ;;
606 --skip-build)
607 DO_BUILD=0
608 ;;
609 --skip-native-rebuild)
610 DO_NATIVE_REBUILD=0
611 ;;
612 -h|--help)
613 usage
614 exit 0
615 ;;
616 *)
617 echo "Unknown option: $1"
618 usage
619 exit 1
620 ;;
621 esac
622 shift
623done
624
625case "$ACTION" in
626 stop)
627 acquire_lock
628 trap cleanup EXIT
629 stop_all
630 exit 0
631 ;;
632 status)
633 show_status
634 exit 0
635 ;;
636esac
637
638ensure_bun_runtime
639
640acquire_lock
641trap cleanup EXIT
642
643ensure_env_defaults
644
645if [[ "$ACTION" == "install" ]]; then
646 install_and_build
647else
648 ensure_node_modules_present
649 ensure_native_compatibility
650fi
651
652if [[ "$DO_START" -eq 0 ]]; then
653 echo "Install/build complete. Start later with: ./install.sh --start-only"
654 echo "Configured web URL: http://localhost:${APP_PORT}"
655 exit 0
656fi
657
658ensure_build_artifacts
659start_background
660print_access_info