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"
10APP_PORT=""
11TS_HTTPS_PORT=""
12TS_AUTHKEY=""
13TS_HOSTNAME=""
14USE_FUNNEL=0
15INSTALL_ARGS=()
16BUN_BIN=""
17
18usage() {
19 cat <<'USAGE'
20Usage: ./install-server.sh [options]
21
22Secure Linux VPS install for Tailscale-first access:
23 - Runs regular app installer
24 - Forces app bind to localhost only (HOST=127.0.0.1)
25 - Installs and starts Tailscale if needed
26 - Publishes app through Tailscale Serve (HTTPS on tailnet)
27
28Options:
29 --port <number> App port (default: 3000; auto-adjusts if already in use)
30 --https-port <number> Tailscale HTTPS serve port (auto-select if omitted)
31 --auth-key <key> Tailscale auth key for non-interactive login
32 --hostname <name> Optional Tailscale device hostname
33 --funnel Also enable Tailscale Funnel (public internet)
34
35Install passthrough options (forwarded to ./install.sh):
36 --no-start
37 --start-only
38 --pm2
39 --nohup
40 --skip-install
41 --skip-build
42
43 -h, --help Show this help
44USAGE
45}
46
47require_command() {
48 local command_name="$1"
49 if ! command -v "$command_name" >/dev/null 2>&1; then
50 echo "Required command not found: $command_name"
51 exit 1
52 fi
53}
54
55ensure_bun_runtime() {
56 install_latest_bun() {
57 if command -v curl >/dev/null 2>&1; then
58 curl -fsSL https://bun.sh/install | bash >/dev/null
59 return 0
60 fi
61 if command -v wget >/dev/null 2>&1; then
62 wget -qO- https://bun.sh/install | bash >/dev/null
63 return 0
64 fi
65
66 echo "❌ Bun is required, and curl/wget is unavailable for auto-install."
67 echo " Install Bun manually: https://bun.com/docs/installation"
68 exit 1
69 }
70
71 resolve_bun_bin() {
72 if command -v bun >/dev/null 2>&1; then
73 command -v bun
74 return 0
75 fi
76 if [[ -x "${HOME}/.bun/bin/bun" ]]; then
77 printf '%s\n' "${HOME}/.bun/bin/bun"
78 return 0
79 fi
80 return 1
81 }
82
83 if ! BUN_BIN="$(resolve_bun_bin)"; then
84 echo "📦 Bun not found. Installing latest Bun..."
85 install_latest_bun
86 BUN_BIN="$(resolve_bun_bin || true)"
87 fi
88
89 if [[ -z "$BUN_BIN" || ! -x "$BUN_BIN" ]]; then
90 echo "❌ Bun could not be resolved."
91 echo " Install Bun manually: https://bun.com/docs/installation"
92 exit 1
93 fi
94
95 export PATH="$(dirname "$BUN_BIN"):$PATH"
96
97 if ! "$BUN_BIN" upgrade >/dev/null 2>&1; then
98 echo "⚠️ Bun auto-upgrade failed. Reinstalling latest Bun..."
99 install_latest_bun
100 BUN_BIN="$(resolve_bun_bin || true)"
101 fi
102
103 if [[ -z "$BUN_BIN" || ! -x "$BUN_BIN" ]]; then
104 echo "❌ Bun could not be resolved after auto-upgrade."
105 echo " Install Bun manually: https://bun.com/docs/installation"
106 exit 1
107 fi
108
109 export PATH="$(dirname "$BUN_BIN"):$PATH"
110
111 local bun_major
112 bun_major="$($BUN_BIN --version | awk -F. '{print $1}' 2>/dev/null || echo 0)"
113 if [[ "$bun_major" -lt 1 ]]; then
114 echo "❌ Bun 1.x+ is required. Current: $($BUN_BIN --version 2>/dev/null || echo 'unknown')"
115 exit 1
116 fi
117}
118
119run_bun() {
120 "$BUN_BIN" "$@"
121}
122
123is_valid_port() {
124 local candidate="$1"
125 [[ "$candidate" =~ ^[0-9]+$ ]] || return 1
126 (( candidate >= 1 && candidate <= 65535 ))
127}
128
129is_local_port_free() {
130 local port="$1"
131 run_bun -e '
132const net = require("node:net");
133const port = Number(process.argv[1]);
134const server = net.createServer();
135server.once("error", () => process.exit(1));
136server.once("listening", () => server.close(() => process.exit(0)));
137server.listen(port, "127.0.0.1");
138' "$port" >/dev/null 2>&1
139}
140
141find_next_free_local_port() {
142 local start_port="$1"
143 local port
144 for port in $(seq "$start_port" 65535); do
145 if is_local_port_free "$port"; then
146 printf '%s\n' "$port"
147 return 0
148 fi
149 done
150 return 1
151}
152
153ensure_linux() {
154 if [[ "$(uname -s)" != "Linux" ]]; then
155 echo "install-server.sh currently supports Linux only."
156 echo "Use ./install.sh on macOS or other environments."
157 exit 1
158 fi
159}
160
161ensure_sudo() {
162 if [[ "$(id -u)" -eq 0 ]]; then
163 return
164 fi
165
166 if ! command -v sudo >/dev/null 2>&1; then
167 echo "sudo is required to install and configure Tailscale on this host."
168 exit 1
169 fi
170}
171
172run_as_root() {
173 if [[ "$(id -u)" -eq 0 ]]; then
174 "$@"
175 return
176 fi
177 sudo "$@"
178}
179
180get_env_value() {
181 local key="$1"
182 if [[ ! -f "$ENV_FILE" ]]; then
183 return 0
184 fi
185 local line
186 line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
187 if [[ -z "$line" ]]; then
188 return 0
189 fi
190 printf '%s\n' "${line#*=}"
191}
192
193upsert_env_value() {
194 local key="$1"
195 local value="$2"
196 touch "$ENV_FILE"
197 local tmp_file
198 tmp_file="$(mktemp)"
199 awk -v key="$key" -v value="$value" '
200 BEGIN { updated = 0 }
201 $0 ~ ("^" key "=") {
202 print key "=" value
203 updated = 1
204 next
205 }
206 { print }
207 END {
208 if (!updated) {
209 print key "=" value
210 }
211 }
212 ' "$ENV_FILE" > "$tmp_file"
213 mv "$tmp_file" "$ENV_FILE"
214}
215
216ensure_local_only_env() {
217 local configured_port
218 configured_port="$(get_env_value PORT)"
219 if [[ -n "$configured_port" && -z "${APP_PORT:-}" ]]; then
220 APP_PORT="$configured_port"
221 fi
222 if [[ -z "${APP_PORT:-}" ]]; then
223 APP_PORT="3000"
224 fi
225
226 if ! is_valid_port "$APP_PORT"; then
227 echo "Invalid port: $APP_PORT"
228 exit 1
229 fi
230
231 if ! is_local_port_free "$APP_PORT"; then
232 local requested_port="$APP_PORT"
233 APP_PORT="$(find_next_free_local_port "$APP_PORT" || true)"
234 if [[ -z "$APP_PORT" ]]; then
235 echo "Could not find a free local port for the app."
236 exit 1
237 fi
238 echo "⚠️ App port ${requested_port} is already in use. Using ${APP_PORT} instead."
239 fi
240
241 upsert_env_value PORT "$APP_PORT"
242 upsert_env_value HOST "127.0.0.1"
243}
244
245run_app_install() {
246 local install_cmd=(bash "$SCRIPT_DIR/install.sh" --port "$APP_PORT")
247 install_cmd+=("${INSTALL_ARGS[@]}")
248 "${install_cmd[@]}"
249}
250
251install_tailscale_if_needed() {
252 if command -v tailscale >/dev/null 2>&1; then
253 echo "✅ Tailscale already installed."
254 return
255 fi
256
257 echo "📦 Installing Tailscale..."
258 if command -v curl >/dev/null 2>&1; then
259 run_as_root bash -c 'curl -fsSL https://tailscale.com/install.sh | sh'
260 elif command -v wget >/dev/null 2>&1; then
261 run_as_root bash -c 'wget -qO- https://tailscale.com/install.sh | sh'
262 else
263 echo "Need curl or wget to install Tailscale automatically."
264 exit 1
265 fi
266
267 if ! command -v tailscale >/dev/null 2>&1; then
268 echo "Tailscale installation did not complete successfully."
269 exit 1
270 fi
271}
272
273ensure_tailscaled_running() {
274 echo "🔧 Ensuring tailscaled is running..."
275 if command -v systemctl >/dev/null 2>&1; then
276 run_as_root systemctl enable --now tailscaled
277 return
278 fi
279
280 if command -v rc-service >/dev/null 2>&1; then
281 run_as_root rc-service tailscaled start || true
282 if command -v rc-update >/dev/null 2>&1; then
283 run_as_root rc-update add tailscaled default || true
284 fi
285 return
286 fi
287
288 if command -v service >/dev/null 2>&1; then
289 run_as_root service tailscaled start || true
290 return
291 fi
292
293 echo "Could not detect init system to start tailscaled automatically."
294 echo "Please start tailscaled manually, then re-run this script."
295 exit 1
296}
297
298ensure_tailscale_connected() {
299 if run_as_root tailscale ip -4 >/dev/null 2>&1; then
300 echo "✅ Tailscale is already connected."
301 return
302 fi
303
304 echo "🔐 Connecting this host to Tailscale..."
305 local up_cmd=(tailscale up)
306
307 if [[ -n "$TS_AUTHKEY" ]]; then
308 up_cmd+=(--authkey "$TS_AUTHKEY")
309 fi
310
311 if [[ -n "$TS_HOSTNAME" ]]; then
312 up_cmd+=(--hostname "$TS_HOSTNAME")
313 fi
314
315 if ! run_as_root "${up_cmd[@]}"; then
316 echo "Failed to connect to Tailscale."
317 if [[ -z "$TS_AUTHKEY" ]]; then
318 echo "Tip: provide --auth-key for non-interactive server setup."
319 fi
320 exit 1
321 fi
322}
323
324get_used_tailscale_https_ports() {
325 local json
326 json="$(run_as_root tailscale serve status --json 2>/dev/null || true)"
327 if [[ -z "$json" || "$json" == "{}" ]]; then
328 return 0
329 fi
330
331 printf '%s' "$json" | run_bun -e '
332const fs = require("node:fs");
333try {
334 const data = JSON.parse(fs.readFileSync(0, "utf8"));
335 const sets = [data?.Web, data?.web, data?.TCP, data?.tcp];
336 const used = new Set();
337 for (const obj of sets) {
338 if (!obj || typeof obj !== "object") continue;
339 for (const key of Object.keys(obj)) {
340 if (/^\d+$/.test(key)) used.add(Number(key));
341 }
342 }
343 process.stdout.write([...used].sort((a, b) => a - b).join("\n"));
344} catch {}
345'
346}
347
348pick_tailscale_https_port() {
349 local preferred="$1"
350 local allow_used_preferred="${2:-0}"
351 local used_ports
352 used_ports="$(get_used_tailscale_https_ports || true)"
353
354 local is_used=0
355 if [[ -n "$preferred" ]]; then
356 if ! is_valid_port "$preferred"; then
357 echo "Invalid Tailscale HTTPS port: $preferred"
358 exit 1
359 fi
360 if [[ -z "$used_ports" ]] || ! grep -qx "$preferred" <<<"$used_ports"; then
361 printf '%s\n' "$preferred"
362 return 0
363 fi
364 if [[ "$allow_used_preferred" -eq 1 ]]; then
365 printf '%s\n' "$preferred"
366 return 0
367 fi
368 is_used=1
369 fi
370
371 local candidate
372 for candidate in 443 8443 9443; do
373 if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then
374 printf '%s\n' "$candidate"
375 return 0
376 fi
377 done
378
379 for candidate in $(seq 10000 65535); do
380 if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then
381 printf '%s\n' "$candidate"
382 return 0
383 fi
384 done
385
386 if [[ "$is_used" -eq 1 ]]; then
387 echo "No free Tailscale HTTPS serve port available (preferred port is already used)."
388 else
389 echo "No free Tailscale HTTPS serve port available."
390 fi
391 exit 1
392}
393
394configure_tailscale_serve() {
395 local preferred_https_port="$TS_HTTPS_PORT"
396 local preferred_from_saved=0
397 local saved_https_port
398 saved_https_port="$(get_env_value TAILSCALE_HTTPS_PORT)"
399 if [[ -z "$preferred_https_port" && -n "$saved_https_port" ]]; then
400 preferred_https_port="$saved_https_port"
401 preferred_from_saved=1
402 fi
403
404 TS_HTTPS_PORT="$(pick_tailscale_https_port "$preferred_https_port" "$preferred_from_saved")"
405 upsert_env_value TAILSCALE_HTTPS_PORT "$TS_HTTPS_PORT"
406
407 if [[ -n "$preferred_https_port" && "$preferred_https_port" != "$TS_HTTPS_PORT" ]]; then
408 echo "⚠️ Tailscale HTTPS port ${preferred_https_port} is already used. Using ${TS_HTTPS_PORT}."
409 fi
410
411 echo "🌐 Configuring Tailscale Serve (HTTPS ${TS_HTTPS_PORT} -> localhost:${APP_PORT})..."
412 if ! run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then
413 run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}"
414 fi
415
416 if [[ "$USE_FUNNEL" -eq 1 ]]; then
417 echo "⚠️ Enabling Tailscale Funnel (public internet exposure)..."
418 if ! run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then
419 run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}"
420 fi
421 fi
422}
423
424get_tailscale_dns_name() {
425 local json
426 json="$(run_as_root tailscale status --json 2>/dev/null || true)"
427 if [[ -z "$json" ]]; then
428 return 0
429 fi
430
431 printf '%s' "$json" | run_bun -e '
432const fs = require("node:fs");
433try {
434 const data = JSON.parse(fs.readFileSync(0, "utf8"));
435 const dnsName = typeof data?.Self?.DNSName === "string" ? data.Self.DNSName : "";
436 process.stdout.write(dnsName.replace(/\.$/, ""));
437} catch {}
438'
439}
440
441get_tailscale_ipv4() {
442 run_as_root tailscale ip -4 2>/dev/null | head -n 1 || true
443}
444
445print_summary() {
446 local dns_name
447 dns_name="$(get_tailscale_dns_name)"
448 local ts_ip
449 ts_ip="$(get_tailscale_ipv4)"
450 local final_url=""
451 local port_suffix=""
452 if [[ "$TS_HTTPS_PORT" != "443" ]]; then
453 port_suffix=":${TS_HTTPS_PORT}"
454 fi
455 if [[ -n "$dns_name" ]]; then
456 final_url="https://${dns_name}${port_suffix}"
457 elif [[ -n "$ts_ip" ]]; then
458 final_url="https://${ts_ip}${port_suffix}"
459 fi
460
461 echo ""
462 echo "Setup complete for Linux server mode."
463 echo ""
464 echo "App binding:"
465 echo " HOST=127.0.0.1 (local-only)"
466 echo " PORT=${APP_PORT}"
467 echo ""
468 echo "Local checks on server:"
469 echo " http://127.0.0.1:${APP_PORT}"
470 echo ""
471 echo "Tailnet access:"
472 if [[ -n "$final_url" ]]; then
473 echo " ${final_url}"
474 echo ""
475 echo "✅ It will be accessible on ${final_url} wherever that person is authenticated on Tailscale."
476 else
477 echo " Run: sudo tailscale status"
478 fi
479
480 if [[ "$USE_FUNNEL" -eq 1 ]]; then
481 echo ""
482 echo "Public access is enabled via Funnel."
483 else
484 echo ""
485 echo "Public internet exposure is disabled."
486 fi
487
488 echo ""
489 echo "Useful commands:"
490 echo " ./install.sh --status"
491 echo " sudo tailscale serve status"
492 if [[ "$USE_FUNNEL" -eq 1 ]]; then
493 echo " sudo tailscale funnel status"
494 fi
495}
496
497while [[ $# -gt 0 ]]; do
498 case "$1" in
499 --port)
500 if [[ $# -lt 2 ]]; then
501 echo "Missing value for --port"
502 exit 1
503 fi
504 APP_PORT="$2"
505 shift
506 ;;
507 --https-port)
508 if [[ $# -lt 2 ]]; then
509 echo "Missing value for --https-port"
510 exit 1
511 fi
512 TS_HTTPS_PORT="$2"
513 shift
514 ;;
515 --auth-key)
516 if [[ $# -lt 2 ]]; then
517 echo "Missing value for --auth-key"
518 exit 1
519 fi
520 TS_AUTHKEY="$2"
521 shift
522 ;;
523 --hostname)
524 if [[ $# -lt 2 ]]; then
525 echo "Missing value for --hostname"
526 exit 1
527 fi
528 TS_HOSTNAME="$2"
529 shift
530 ;;
531 --funnel)
532 USE_FUNNEL=1
533 ;;
534 --pm2|--nohup|--skip-install|--skip-build|--no-start|--start-only)
535 INSTALL_ARGS+=("$1")
536 ;;
537 -h|--help)
538 usage
539 exit 0
540 ;;
541 *)
542 echo "Unknown option: $1"
543 usage
544 exit 1
545 ;;
546 esac
547 shift
548done
549
550ensure_linux
551ensure_sudo
552require_command bash
553require_command git
554ensure_bun_runtime
555
556ensure_local_only_env
557run_app_install
558install_tailscale_if_needed
559ensure_tailscaled_running
560ensure_tailscale_connected
561configure_tailscale_serve
562print_summary