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"
6SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7cd "$SCRIPT_DIR"
8
9ENV_FILE="$SCRIPT_DIR/.env"
10RUNTIME_DIR="$SCRIPT_DIR/data/runtime"
11PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid"
12LOG_FILE="$RUNTIME_DIR/${APP_NAME}.log"
13
14ACTION="install"
15DO_INSTALL=1
16DO_BUILD=1
17DO_START=1
18RUNNER="auto"
19PORT_OVERRIDE=""
20APP_PORT=""
21ACTIVE_RUNNER=""
22CREATED_JWT_SECRET=0
23
24usage() {
25 cat <<'USAGE'
26Usage: ./install.sh [options]
27
28Default 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
34Options:
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
45USAGE
46}
47
48require_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
56is_valid_port() {
57 local candidate="$1"
58 [[ "$candidate" =~ ^[0-9]+$ ]] || return 1
59 (( candidate >= 1 && candidate <= 65535 ))
60}
61
62get_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
75upsert_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
98ensure_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
128ensure_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
135install_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
147stop_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
169start_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
187stop_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
201start_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
216start_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
243wait_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
260print_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
288show_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
319stop_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
336while [[ $# -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
384done
385
386case "$ACTION" in
387 stop)
388 stop_all
389 exit 0
390 ;;
391 status)
392 show_status
393 exit 0
394 ;;
395esac
396
397require_command node
398require_command npm
399
400ensure_env_defaults
401
402if [[ "$ACTION" == "install" ]]; then
403 install_and_build
404fi
405
406if [[ "$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
410fi
411
412ensure_build_artifacts
413start_background
414print_access_info