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