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
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=""
23
24STASH_REF=""
25STASH_CREATED=0
26STASH_RESTORED=0
27
28BACKUP_SOURCES=()
29BACKUP_PATHS=()
30
31usage() {
32 cat <<'USAGE'
33Usage: ./update.sh [options]
34
35Default behavior:
36 - Pull latest git changes safely
37 - Install dependencies
38 - Rebuild native modules if needed
39 - Build server + web dashboard
40 - Restart existing runtime (PM2 or nohup) when possible
41
42Options:
43 --remote <name> Git remote to pull from (default: origin or first remote)
44 --branch <name> Git branch to pull (default: current branch or remote HEAD)
45 --skip-install Skip npm install
46 --skip-build Skip npm run build
47 --skip-native-rebuild Skip native-module rebuild checks
48 --no-restart Do not restart process after update
49 -h, --help Show this help
50USAGE
51}
52
53require_command() {
54 local command_name="$1"
55 if ! command -v "$command_name" >/dev/null 2>&1; then
56 echo "❌ Required command not found: $command_name"
57 exit 1
58 fi
59}
60
61check_node_version() {
62 local node_major
63 node_major="$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0)"
64 if [[ "$node_major" -lt 22 ]]; then
65 echo "❌ Node.js 22+ is required. Current: $(node -v 2>/dev/null || echo 'unknown')"
66 exit 1
67 fi
68}
69
70acquire_lock() {
71 mkdir -p "$RUNTIME_DIR"
72 if ! mkdir "$LOCK_DIR" 2>/dev/null; then
73 echo "❌ Another update appears to be running."
74 echo " If this is stale, remove: $LOCK_DIR"
75 exit 1
76 fi
77}
78
79release_lock() {
80 rmdir "$LOCK_DIR" >/dev/null 2>&1 || true
81}
82
83backup_file() {
84 local file="$1"
85 if [[ ! -f "$file" ]]; then
86 return 0
87 fi
88
89 local base
90 base="$(basename "$file")"
91 local backup_path
92 backup_path="$(mktemp "${TMPDIR:-/tmp}/tweets2bsky-${base}.XXXXXX")"
93 cp "$file" "$backup_path"
94 BACKUP_SOURCES+=("$file")
95 BACKUP_PATHS+=("$backup_path")
96}
97
98restore_backups() {
99 local idx
100 for idx in "${!BACKUP_SOURCES[@]}"; do
101 local src="${BACKUP_SOURCES[$idx]}"
102 local bak="${BACKUP_PATHS[$idx]}"
103 if [[ -f "$bak" ]]; then
104 cp "$bak" "$src"
105 rm -f "$bak"
106 fi
107 done
108}
109
110cleanup() {
111 restore_backups
112 release_lock
113}
114
115ensure_git_repo() {
116 if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
117 echo "❌ This directory is not a git repository: $SCRIPT_DIR"
118 exit 1
119 fi
120}
121
122resolve_remote() {
123 if [[ -n "$REMOTE_OVERRIDE" ]]; then
124 if ! git remote | grep -qx "$REMOTE_OVERRIDE"; then
125 echo "❌ Remote '$REMOTE_OVERRIDE' does not exist."
126 exit 1
127 fi
128 printf '%s\n' "$REMOTE_OVERRIDE"
129 return 0
130 fi
131
132 if git remote | grep -qx "origin"; then
133 printf '%s\n' "origin"
134 return 0
135 fi
136
137 local first_remote
138 first_remote="$(git remote | head -n 1)"
139 if [[ -z "$first_remote" ]]; then
140 echo "❌ No git remote configured."
141 exit 1
142 fi
143
144 printf '%s\n' "$first_remote"
145}
146
147resolve_branch() {
148 local remote="$1"
149
150 if [[ -n "$BRANCH_OVERRIDE" ]]; then
151 printf '%s\n' "$BRANCH_OVERRIDE"
152 return 0
153 fi
154
155 local current_branch
156 current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
157 if [[ -n "$current_branch" ]] && git show-ref --verify --quiet "refs/remotes/${remote}/${current_branch}"; then
158 printf '%s\n' "$current_branch"
159 return 0
160 fi
161
162 local remote_head
163 remote_head="$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null || true)"
164 if [[ -n "$remote_head" ]]; then
165 printf '%s\n' "${remote_head#${remote}/}"
166 return 0
167 fi
168
169 if git show-ref --verify --quiet "refs/remotes/${remote}/main"; then
170 printf '%s\n' "main"
171 return 0
172 fi
173
174 if git show-ref --verify --quiet "refs/remotes/${remote}/master"; then
175 printf '%s\n' "master"
176 return 0
177 fi
178
179 if [[ -n "$current_branch" ]]; then
180 printf '%s\n' "$current_branch"
181 return 0
182 fi
183
184 echo "❌ Could not determine target branch for remote '$remote'."
185 exit 1
186}
187
188working_tree_dirty() {
189 [[ -n "$(git status --porcelain --untracked-files=normal)" ]]
190}
191
192stash_local_changes() {
193 if ! working_tree_dirty; then
194 return 0
195 fi
196
197 echo "🧳 Stashing local changes before update..."
198
199 local before after message
200 before="$(git stash list -n 1 --format=%gd || true)"
201 message="tweets-2-bsky-update-autostash-$(date -u +%Y%m%d-%H%M%S)"
202 git stash push -u -m "$message" >/dev/null
203 after="$(git stash list -n 1 --format=%gd || true)"
204
205 if [[ -n "$after" && "$after" != "$before" ]]; then
206 STASH_REF="$after"
207 STASH_CREATED=1
208 echo "✅ Saved local changes to $STASH_REF"
209 fi
210}
211
212restore_stash_if_needed() {
213 if [[ "$STASH_CREATED" -ne 1 || -z "$STASH_REF" ]]; then
214 return 0
215 fi
216
217 echo "🔁 Restoring stashed local changes ($STASH_REF)..."
218 if git stash apply --index "$STASH_REF" >/dev/null 2>&1; then
219 git stash drop "$STASH_REF" >/dev/null 2>&1 || true
220 STASH_RESTORED=1
221 echo "✅ Restored local changes from stash."
222 else
223 echo "⚠️ Could not auto-apply $STASH_REF cleanly."
224 echo " Your changes are still preserved in stash."
225 echo " Review manually with: git stash show -p $STASH_REF"
226 fi
227}
228
229checkout_branch() {
230 local remote="$1"
231 local target_branch="$2"
232
233 if ! git show-ref --verify --quiet "refs/remotes/${remote}/${target_branch}"; then
234 echo "❌ Remote branch not found: ${remote}/${target_branch}"
235 exit 1
236 fi
237
238 local current_branch
239 current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
240
241 if [[ "$current_branch" == "$target_branch" ]]; then
242 return 0
243 fi
244
245 if git show-ref --verify --quiet "refs/heads/${target_branch}"; then
246 git switch "$target_branch" >/dev/null 2>&1 || git checkout "$target_branch" >/dev/null 2>&1
247 else
248 git switch -c "$target_branch" --track "${remote}/${target_branch}" >/dev/null 2>&1 || \
249 git checkout -b "$target_branch" --track "${remote}/${target_branch}" >/dev/null 2>&1
250 fi
251}
252
253pull_latest() {
254 local remote="$1"
255 local branch="$2"
256
257 echo "⬇️ Fetching latest changes from $remote..."
258 git fetch "$remote" --prune
259
260 checkout_branch "$remote" "$branch"
261 git branch --set-upstream-to="${remote}/${branch}" "$branch" >/dev/null 2>&1 || true
262
263 echo "🔄 Pulling latest changes from ${remote}/${branch}..."
264 if ! git pull --ff-only "$remote" "$branch"; then
265 echo "ℹ️ Fast-forward pull failed, retrying with rebase..."
266 git pull --rebase "$remote" "$branch"
267 fi
268}
269
270native_module_compatible() {
271 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
272}
273
274rebuild_native_modules() {
275 if [[ "$DO_NATIVE_REBUILD" -eq 0 ]]; then
276 return 0
277 fi
278
279 if native_module_compatible; then
280 return 0
281 fi
282
283 echo "🔧 Native module mismatch detected, rebuilding..."
284 if npm run rebuild:native; then
285 return 0
286 fi
287
288 echo "⚠️ rebuild:native failed, trying npm rebuild better-sqlite3..."
289 if npm rebuild better-sqlite3; then
290 return 0
291 fi
292
293 npm rebuild better-sqlite3 --build-from-source
294}
295
296install_dependencies() {
297 if [[ "$DO_INSTALL" -ne 1 ]]; then
298 return 0
299 fi
300
301 echo "📦 Installing dependencies..."
302 npm install --no-audit --no-fund
303}
304
305build_project() {
306 if [[ "$DO_BUILD" -ne 1 ]]; then
307 return 0
308 fi
309
310 echo "🏗️ Building server + web dashboard..."
311 npm run build
312}
313
314pm2_has_process() {
315 local name="$1"
316 command -v pm2 >/dev/null 2>&1 && pm2 describe "$name" >/dev/null 2>&1
317}
318
319nohup_process_running() {
320 if [[ ! -f "$PID_FILE" ]]; then
321 return 1
322 fi
323
324 local pid
325 pid="$(cat "$PID_FILE" 2>/dev/null || true)"
326 if [[ -z "$pid" ]]; then
327 return 1
328 fi
329
330 if ! kill -0 "$pid" >/dev/null 2>&1; then
331 return 1
332 fi
333
334 local cmd
335 cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
336 [[ "$cmd" == *"dist/index.js"* || "$cmd" == *"npm start"* || "$cmd" == *"$APP_NAME"* ]]
337}
338
339restart_runtime() {
340 if [[ "$DO_RESTART" -ne 1 ]]; then
341 echo "⏭️ Skipping restart (--no-restart)."
342 return 0
343 fi
344
345 echo "🔄 Restarting runtime..."
346
347 if pm2_has_process "$APP_NAME"; then
348 pm2 restart "$APP_NAME" --update-env >/dev/null 2>&1 || {
349 echo "⚠️ PM2 restart failed for $APP_NAME. Recreating process..."
350 pm2 delete "$APP_NAME" >/dev/null 2>&1 || true
351 pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env >/dev/null 2>&1
352 }
353 pm2 save >/dev/null 2>&1 || true
354 echo "✅ Restarted PM2 process: $APP_NAME"
355 return 0
356 fi
357
358 if pm2_has_process "$LEGACY_APP_NAME"; then
359 pm2 restart "$LEGACY_APP_NAME" --update-env >/dev/null 2>&1 || {
360 echo "⚠️ PM2 restart failed for $LEGACY_APP_NAME. Recreating under $APP_NAME..."
361 pm2 delete "$LEGACY_APP_NAME" >/dev/null 2>&1 || true
362 pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env >/dev/null 2>&1
363 }
364 pm2 save >/dev/null 2>&1 || true
365 echo "✅ Restarted PM2 process."
366 return 0
367 fi
368
369 if nohup_process_running; then
370 bash "$SCRIPT_DIR/install.sh" --start-only --nohup --skip-native-rebuild >/dev/null
371 echo "✅ Restarted nohup runtime."
372 return 0
373 fi
374
375 if command -v pm2 >/dev/null 2>&1; then
376 bash "$SCRIPT_DIR/install.sh" --start-only --pm2 --skip-native-rebuild >/dev/null
377 echo "✅ Started PM2 runtime (was not running)."
378 return 0
379 fi
380
381 bash "$SCRIPT_DIR/install.sh" --start-only --nohup --skip-native-rebuild >/dev/null
382 echo "✅ Started nohup runtime (was not running)."
383}
384
385print_summary() {
386 echo ""
387 echo "✅ Update complete!"
388 echo ""
389 echo "Current commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
390 echo "Current branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')"
391
392 if [[ "$STASH_CREATED" -eq 1 ]]; then
393 if [[ "$STASH_RESTORED" -eq 1 ]]; then
394 echo "Stash restore: restored"
395 else
396 echo "Stash restore: pending manual apply ($STASH_REF)"
397 fi
398 fi
399}
400
401while [[ $# -gt 0 ]]; do
402 case "$1" in
403 --remote)
404 if [[ $# -lt 2 ]]; then
405 echo "Missing value for --remote"
406 exit 1
407 fi
408 REMOTE_OVERRIDE="$2"
409 shift
410 ;;
411 --branch)
412 if [[ $# -lt 2 ]]; then
413 echo "Missing value for --branch"
414 exit 1
415 fi
416 BRANCH_OVERRIDE="$2"
417 shift
418 ;;
419 --skip-install)
420 DO_INSTALL=0
421 ;;
422 --skip-build)
423 DO_BUILD=0
424 ;;
425 --skip-native-rebuild)
426 DO_NATIVE_REBUILD=0
427 ;;
428 --no-restart)
429 DO_RESTART=0
430 ;;
431 -h|--help)
432 usage
433 exit 0
434 ;;
435 *)
436 echo "Unknown option: $1"
437 usage
438 exit 1
439 ;;
440 esac
441 shift
442done
443
444echo "🔄 Tweets-2-Bsky Updater"
445echo "========================="
446
447require_command git
448require_command node
449require_command npm
450check_node_version
451ensure_git_repo
452
453acquire_lock
454trap cleanup EXIT
455
456backup_file "$CONFIG_FILE"
457backup_file "$ENV_FILE"
458
459stash_local_changes
460
461remote="$(resolve_remote)"
462branch="$(resolve_branch "$remote")"
463
464pull_latest "$remote" "$branch"
465install_dependencies
466rebuild_native_modules
467build_project
468restart_runtime
469restore_stash_if_needed
470print_summary