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
10RUNTIME_DIR="$SCRIPT_DIR/data/runtime"
11PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid"
12LOCK_DIR="$RUNTIME_DIR/.update.lock"
13
14CONFIG_FILE="$SCRIPT_DIR/config.json"
15ENV_FILE="$SCRIPT_DIR/.env"
16
17DO_INSTALL=1
18DO_BUILD=1
19DO_NATIVE_REBUILD=1
20DO_RESTART=1
21REMOTE_OVERRIDE=""
22BRANCH_OVERRIDE=""
23ORIGINAL_ARGS=("$@")
24UPDATE_SH_REEXECED="${UPDATE_SH_REEXECED:-0}"
25
26STASH_REF=""
27STASH_CREATED=0
28STASH_RESTORED=0
29UNTRACKED_COUNT=0
30
31BACKUP_SOURCES=()
32BACKUP_PATHS=()
33BUN_BIN=""
34ORIGINAL_SCRIPT_CHECKSUM=""
35
36usage() {
37 cat <<'USAGE'
38Usage: ./update.sh [options]
39
40Default behavior:
41 - Pull latest git changes safely
42 - Install dependencies
43 - Rebuild native modules if needed
44 - Build server + web dashboard
45 - Restart existing runtime (PM2 or nohup) when possible
46
47Options:
48 --remote <name> Git remote to pull from (default: origin or first remote)
49 --branch <name> Git branch to pull (default: current branch or remote HEAD)
50 --skip-install Skip bun install
51 --skip-build Skip bun run build
52 --skip-native-rebuild Skip native-module rebuild checks
53 --no-restart Do not restart process after update
54 -h, --help Show this help
55USAGE
56}
57
58require_command() {
59 local command_name="$1"
60 if ! command -v "$command_name" >/dev/null 2>&1; then
61 echo "❌ Required command not found: $command_name"
62 exit 1
63 fi
64}
65
66ensure_bun_runtime() {
67 install_latest_bun() {
68 if command -v curl >/dev/null 2>&1; then
69 curl -fsSL https://bun.sh/install | bash >/dev/null
70 return 0
71 fi
72 if command -v wget >/dev/null 2>&1; then
73 wget -qO- https://bun.sh/install | bash >/dev/null
74 return 0
75 fi
76
77 echo "❌ Bun is required, and curl/wget is unavailable for auto-install."
78 echo " Install Bun manually: https://bun.com/docs/installation"
79 exit 1
80 }
81
82 resolve_bun_bin() {
83 if command -v bun >/dev/null 2>&1; then
84 command -v bun
85 return 0
86 fi
87 if [[ -x "${HOME}/.bun/bin/bun" ]]; then
88 printf '%s\n' "${HOME}/.bun/bin/bun"
89 return 0
90 fi
91 return 1
92 }
93
94 if ! BUN_BIN="$(resolve_bun_bin)"; then
95 echo "📦 Bun not found. Installing latest Bun..."
96 install_latest_bun
97 BUN_BIN="$(resolve_bun_bin || true)"
98 fi
99
100 if [[ -z "$BUN_BIN" || ! -x "$BUN_BIN" ]]; then
101 echo "❌ Bun could not be resolved."
102 echo " Install Bun manually: https://bun.com/docs/installation"
103 exit 1
104 fi
105
106 export PATH="$(dirname "$BUN_BIN"):$PATH"
107
108 if ! "$BUN_BIN" upgrade >/dev/null 2>&1; then
109 echo "⚠️ Bun auto-upgrade failed. Reinstalling latest Bun..."
110 install_latest_bun
111 BUN_BIN="$(resolve_bun_bin || true)"
112 fi
113
114 if [[ -z "$BUN_BIN" || ! -x "$BUN_BIN" ]]; then
115 echo "❌ Bun could not be resolved after auto-upgrade."
116 echo " Install Bun manually: https://bun.com/docs/installation"
117 exit 1
118 fi
119
120 export PATH="$(dirname "$BUN_BIN"):$PATH"
121
122 local bun_major
123 bun_major="$($BUN_BIN --version | awk -F. '{print $1}' 2>/dev/null || echo 0)"
124 if [[ "$bun_major" -lt 1 ]]; then
125 echo "❌ Bun 1.x+ is required. Current: $($BUN_BIN --version 2>/dev/null || echo 'unknown')"
126 exit 1
127 fi
128}
129
130run_bun() {
131 "$BUN_BIN" "$@"
132}
133
134compute_file_checksum() {
135 local file="$1"
136
137 if command -v sha256sum >/dev/null 2>&1; then
138 sha256sum "$file" | awk '{print $1}'
139 return 0
140 fi
141
142 if command -v shasum >/dev/null 2>&1; then
143 shasum -a 256 "$file" | awk '{print $1}'
144 return 0
145 fi
146
147 if command -v openssl >/dev/null 2>&1; then
148 openssl dgst -sha256 "$file" | awk '{print $NF}'
149 return 0
150 fi
151
152 return 1
153}
154
155acquire_lock() {
156 mkdir -p "$RUNTIME_DIR"
157 if ! mkdir "$LOCK_DIR" 2>/dev/null; then
158 echo "❌ Another update appears to be running."
159 echo " If this is stale, remove: $LOCK_DIR"
160 exit 1
161 fi
162}
163
164release_lock() {
165 rmdir "$LOCK_DIR" >/dev/null 2>&1 || true
166}
167
168backup_file() {
169 local file="$1"
170 if [[ ! -f "$file" ]]; then
171 return 0
172 fi
173
174 local base
175 base="$(basename "$file")"
176 local backup_path
177 backup_path="$(mktemp_file "tweets2bsky-${base}")"
178 cp "$file" "$backup_path"
179 BACKUP_SOURCES+=("$file")
180 BACKUP_PATHS+=("$backup_path")
181}
182
183restore_backups() {
184 local idx
185 for idx in "${!BACKUP_SOURCES[@]}"; do
186 local src="${BACKUP_SOURCES[$idx]}"
187 local bak="${BACKUP_PATHS[$idx]}"
188 if [[ -f "$bak" ]]; then
189 cp "$bak" "$src"
190 rm -f "$bak"
191 fi
192 done
193}
194
195cleanup() {
196 restore_backups
197 release_lock
198}
199
200mktemp_file() {
201 local prefix="$1"
202
203 if mktemp --version >/dev/null 2>&1; then
204 mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX"
205 return 0
206 fi
207
208 local tmp_root
209 tmp_root="${TMPDIR:-/tmp}"
210 mktemp -t "${prefix}.XXXXXX" 2>/dev/null || mktemp "${tmp_root}/${prefix}.XXXXXX"
211}
212
213ensure_git_repo() {
214 if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
215 echo "❌ This directory is not a git repository: $SCRIPT_DIR"
216 exit 1
217 fi
218}
219
220resolve_remote() {
221 if [[ -n "$REMOTE_OVERRIDE" ]]; then
222 if ! git remote | grep -qx "$REMOTE_OVERRIDE"; then
223 echo "❌ Remote '$REMOTE_OVERRIDE' does not exist."
224 exit 1
225 fi
226 printf '%s\n' "$REMOTE_OVERRIDE"
227 return 0
228 fi
229
230 if git remote | grep -qx "origin"; then
231 printf '%s\n' "origin"
232 return 0
233 fi
234
235 local first_remote
236 first_remote="$(git remote | head -n 1)"
237 if [[ -z "$first_remote" ]]; then
238 echo "❌ No git remote configured."
239 exit 1
240 fi
241
242 printf '%s\n' "$first_remote"
243}
244
245resolve_branch() {
246 local remote="$1"
247
248 if [[ -n "$BRANCH_OVERRIDE" ]]; then
249 printf '%s\n' "$BRANCH_OVERRIDE"
250 return 0
251 fi
252
253 local current_branch
254 current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
255 if [[ -n "$current_branch" ]] && git show-ref --verify --quiet "refs/remotes/${remote}/${current_branch}"; then
256 printf '%s\n' "$current_branch"
257 return 0
258 fi
259
260 local remote_head
261 remote_head="$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null || true)"
262 if [[ -n "$remote_head" ]]; then
263 printf '%s\n' "${remote_head#${remote}/}"
264 return 0
265 fi
266
267 if git show-ref --verify --quiet "refs/remotes/${remote}/main"; then
268 printf '%s\n' "main"
269 return 0
270 fi
271
272 if git show-ref --verify --quiet "refs/remotes/${remote}/master"; then
273 printf '%s\n' "master"
274 return 0
275 fi
276
277 if [[ -n "$current_branch" ]]; then
278 printf '%s\n' "$current_branch"
279 return 0
280 fi
281
282 echo "❌ Could not determine target branch for remote '$remote'."
283 exit 1
284}
285
286tracked_tree_dirty() {
287 [[ -n "$(git status --porcelain --untracked-files=no)" ]]
288}
289
290stash_local_changes() {
291 if ! tracked_tree_dirty; then
292 return 0
293 fi
294
295 echo "🧳 Stashing local changes before update..."
296
297 local before after message
298 before="$(git stash list -n 1 --format=%gd || true)"
299 message="tweets-2-bsky-update-autostash-$(date -u +%Y%m%d-%H%M%S)"
300 git stash push -m "$message" >/dev/null
301 after="$(git stash list -n 1 --format=%gd || true)"
302
303 if [[ -n "$after" && "$after" != "$before" ]]; then
304 STASH_REF="$after"
305 STASH_CREATED=1
306 echo "✅ Saved local changes to $STASH_REF"
307 fi
308}
309
310print_untracked_notice() {
311 local count
312 count="$(git ls-files --others --exclude-standard | wc -l | tr -d '[:space:]')"
313 UNTRACKED_COUNT="$count"
314
315 if [[ "$count" -gt 0 ]]; then
316 echo "ℹ️ Leaving $count untracked file(s) untouched (not stashed)."
317 echo " This avoids slow/hanging updates on large data directories."
318 fi
319}
320
321restore_stash_if_needed() {
322 if [[ "$STASH_CREATED" -ne 1 || -z "$STASH_REF" ]]; then
323 return 0
324 fi
325
326 echo "🔁 Restoring stashed local changes ($STASH_REF)..."
327 if git stash apply --index "$STASH_REF" >/dev/null 2>&1; then
328 git stash drop "$STASH_REF" >/dev/null 2>&1 || true
329 STASH_RESTORED=1
330 echo "✅ Restored local changes from stash."
331 else
332 echo "⚠️ Could not auto-apply $STASH_REF cleanly."
333 echo " Your changes are still preserved in stash."
334 echo " Review manually with: git stash show -p $STASH_REF"
335 fi
336}
337
338checkout_branch() {
339 local remote="$1"
340 local target_branch="$2"
341
342 if ! git show-ref --verify --quiet "refs/remotes/${remote}/${target_branch}"; then
343 echo "❌ Remote branch not found: ${remote}/${target_branch}"
344 exit 1
345 fi
346
347 local current_branch
348 current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
349
350 if [[ "$current_branch" == "$target_branch" ]]; then
351 return 0
352 fi
353
354 if git show-ref --verify --quiet "refs/heads/${target_branch}"; then
355 git switch "$target_branch" >/dev/null 2>&1 || git checkout "$target_branch" >/dev/null 2>&1
356 else
357 git switch -c "$target_branch" --track "${remote}/${target_branch}" >/dev/null 2>&1 || \
358 git checkout -b "$target_branch" --track "${remote}/${target_branch}" >/dev/null 2>&1
359 fi
360}
361
362pull_latest() {
363 local remote="$1"
364 local branch="$2"
365
366 echo "⬇️ Fetching latest changes from $remote..."
367 git fetch "$remote" --prune
368
369 checkout_branch "$remote" "$branch"
370 git branch --set-upstream-to="${remote}/${branch}" "$branch" >/dev/null 2>&1 || true
371
372 echo "🔄 Pulling latest changes from ${remote}/${branch}..."
373 if ! git pull --ff-only "$remote" "$branch"; then
374 echo "ℹ️ Fast-forward pull failed, retrying with rebase..."
375 git pull --rebase "$remote" "$branch"
376 fi
377}
378
379reexec_with_latest_updater_if_changed() {
380 if [[ "$UPDATE_SH_REEXECED" == "1" ]]; then
381 return 0
382 fi
383
384 if [[ -z "$ORIGINAL_SCRIPT_CHECKSUM" ]]; then
385 return 0
386 fi
387
388 local current_checksum
389 current_checksum="$(compute_file_checksum "$SCRIPT_DIR/update.sh" 2>/dev/null || true)"
390 if [[ -z "$current_checksum" || "$current_checksum" == "$ORIGINAL_SCRIPT_CHECKSUM" ]]; then
391 return 0
392 fi
393
394 echo "♻️ update.sh was updated during pull. Restarting updater with newest logic..."
395
396 restore_stash_if_needed
397 trap - EXIT
398 cleanup
399
400 UPDATE_SH_REEXECED=1 exec bash "$SCRIPT_DIR/update.sh" "${ORIGINAL_ARGS[@]}"
401}
402
403native_module_compatible() {
404 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
405}
406
407rebuild_native_modules() {
408 if [[ "$DO_NATIVE_REBUILD" -eq 0 ]]; then
409 return 0
410 fi
411
412 if native_module_compatible; then
413 return 0
414 fi
415
416 echo "🔧 Native module mismatch detected, rebuilding..."
417 if run_bun run rebuild:native; then
418 return 0
419 fi
420
421 echo "⚠️ rebuild:native failed, forcing fresh Bun install..."
422 run_bun install --force
423}
424
425install_dependencies() {
426 if [[ "$DO_INSTALL" -ne 1 ]]; then
427 return 0
428 fi
429
430 echo "📦 Installing dependencies..."
431 run_bun install
432}
433
434build_project() {
435 if [[ "$DO_BUILD" -ne 1 ]]; then
436 return 0
437 fi
438
439 echo "🏗️ Building server + web dashboard..."
440 run_bun run build
441}
442
443pm2_has_process() {
444 local name="$1"
445 command -v pm2 >/dev/null 2>&1 && pm2 describe "$name" >/dev/null 2>&1
446}
447
448start_pm2_process_with_bun() {
449 local name="$1"
450 pm2 delete "$name" >/dev/null 2>&1 || true
451 pm2 start "$BUN_BIN" --name "$name" --cwd "$SCRIPT_DIR" --update-env -- dist/index.js
452}
453
454nohup_process_running() {
455 if [[ ! -f "$PID_FILE" ]]; then
456 return 1
457 fi
458
459 local pid
460 pid="$(cat "$PID_FILE" 2>/dev/null || true)"
461 if [[ -z "$pid" ]]; then
462 return 1
463 fi
464
465 if ! kill -0 "$pid" >/dev/null 2>&1; then
466 return 1
467 fi
468
469 local cmd
470 cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
471 [[ "$cmd" == *"dist/index.js"* || "$cmd" == *"bun run start"* || "$cmd" == *"bun dist/index.js"* || "$cmd" == *"$APP_NAME"* ]]
472}
473
474restart_runtime() {
475 if [[ "$DO_RESTART" -ne 1 ]]; then
476 echo "⏭️ Skipping restart (--no-restart)."
477 return 0
478 fi
479
480 echo "🔄 Restarting runtime..."
481
482 if command -v pm2 >/dev/null 2>&1; then
483 local has_app=0
484 local has_legacy=0
485
486 if pm2_has_process "$APP_NAME"; then
487 has_app=1
488 fi
489 if pm2_has_process "$LEGACY_APP_NAME"; then
490 has_legacy=1
491 fi
492
493 if [[ "$has_app" -eq 1 && "$has_legacy" -eq 1 ]]; then
494 echo "ℹ️ Found both PM2 processes ($APP_NAME and $LEGACY_APP_NAME). Consolidating to $APP_NAME..."
495 echo "[pm2] Recreating $APP_NAME with Bun binary launcher"
496 start_pm2_process_with_bun "$APP_NAME"
497 echo "[pm2] Removing duplicate legacy process $LEGACY_APP_NAME"
498 pm2 delete "$LEGACY_APP_NAME" || true
499 echo "[pm2] Saving PM2 process list"
500 pm2 save || true
501 echo "✅ Restarted PM2 process: $APP_NAME"
502 return 0
503 fi
504
505 if [[ "$has_app" -eq 1 ]]; then
506 echo "[pm2] Recreating $APP_NAME with Bun binary launcher"
507 start_pm2_process_with_bun "$APP_NAME"
508 echo "[pm2] Saving PM2 process list"
509 pm2 save || true
510 echo "✅ Restarted PM2 process: $APP_NAME"
511 return 0
512 fi
513
514 if [[ "$has_legacy" -eq 1 ]]; then
515 echo "[pm2] Recreating legacy process $LEGACY_APP_NAME with Bun binary launcher"
516 start_pm2_process_with_bun "$LEGACY_APP_NAME"
517 echo "[pm2] Saving PM2 process list"
518 pm2 save || true
519 echo "✅ Restarted PM2 process: $LEGACY_APP_NAME"
520 return 0
521 fi
522 fi
523
524 if nohup_process_running; then
525 bash "$SCRIPT_DIR/install.sh" --start-only --nohup --skip-native-rebuild >/dev/null
526 echo "✅ Restarted nohup runtime."
527 return 0
528 fi
529
530 if command -v pm2 >/dev/null 2>&1; then
531 bash "$SCRIPT_DIR/install.sh" --start-only --pm2 --skip-native-rebuild >/dev/null
532 echo "✅ Started PM2 runtime (was not running)."
533 return 0
534 fi
535
536 bash "$SCRIPT_DIR/install.sh" --start-only --nohup --skip-native-rebuild >/dev/null
537 echo "✅ Started nohup runtime (was not running)."
538}
539
540print_summary() {
541 echo ""
542 echo "✅ Update complete!"
543 echo ""
544 echo "Current commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
545 echo "Current branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')"
546
547 if [[ "$STASH_CREATED" -eq 1 ]]; then
548 if [[ "$STASH_RESTORED" -eq 1 ]]; then
549 echo "Stash restore: restored"
550 else
551 echo "Stash restore: pending manual apply ($STASH_REF)"
552 fi
553 fi
554}
555
556while [[ $# -gt 0 ]]; do
557 case "$1" in
558 --remote)
559 if [[ $# -lt 2 ]]; then
560 echo "Missing value for --remote"
561 exit 1
562 fi
563 REMOTE_OVERRIDE="$2"
564 shift
565 ;;
566 --branch)
567 if [[ $# -lt 2 ]]; then
568 echo "Missing value for --branch"
569 exit 1
570 fi
571 BRANCH_OVERRIDE="$2"
572 shift
573 ;;
574 --skip-install)
575 DO_INSTALL=0
576 ;;
577 --skip-build)
578 DO_BUILD=0
579 ;;
580 --skip-native-rebuild)
581 DO_NATIVE_REBUILD=0
582 ;;
583 --no-restart)
584 DO_RESTART=0
585 ;;
586 -h|--help)
587 usage
588 exit 0
589 ;;
590 *)
591 echo "Unknown option: $1"
592 usage
593 exit 1
594 ;;
595 esac
596 shift
597done
598
599echo "🔄 Tweets-2-Bsky Updater"
600echo "========================="
601
602require_command git
603ensure_bun_runtime
604ensure_git_repo
605
606ORIGINAL_SCRIPT_CHECKSUM="$(compute_file_checksum "$SCRIPT_DIR/update.sh" 2>/dev/null || true)"
607
608acquire_lock
609trap cleanup EXIT
610
611backup_file "$CONFIG_FILE"
612backup_file "$ENV_FILE"
613
614stash_local_changes
615print_untracked_notice
616
617remote="$(resolve_remote)"
618branch="$(resolve_branch "$remote")"
619
620pull_latest "$remote" "$branch"
621reexec_with_latest_updater_if_changed
622install_dependencies
623rebuild_native_modules
624build_project
625restart_runtime
626restore_stash_if_needed
627print_summary