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.
···4747./install.sh --stop
4848./install.sh --status
4949./install.sh --port 3100
5050+./install.sh --host 127.0.0.1
5151+./install.sh --skip-native-rebuild
5052```
51535254If you prefer full manual setup, skip to [Manual Setup](#manual-setup-technical).
5555+5656+## Linux VPS Without Domain (Secure HTTPS via Tailscale)
5757+5858+If you host on a public VPS (Linux) and do not own a domain, use the server installer:
5959+6060+```bash
6161+chmod +x install-server.sh
6262+./install-server.sh
6363+```
6464+6565+What this does:
6666+6767+- runs the normal app install/build/start flow
6868+- auto-selects a free local app port if your chosen/default port is already in use
6969+- forces the app to bind locally only (`HOST=127.0.0.1`)
7070+- installs and starts Tailscale if needed
7171+- configures `tailscale serve` on a free HTTPS port so your dashboard is reachable over Tailnet HTTPS
7272+- prints the final Tailnet URL to open from any device authenticated on your Tailscale account
7373+7474+Optional non-interactive login:
7575+7676+```bash
7777+./install-server.sh --auth-key <TS_AUTHKEY>
7878+```
7979+8080+Optional fixed Tailscale HTTPS port:
8181+8282+```bash
8383+./install-server.sh --https-port 443
8484+```
8585+8686+Optional public exposure (internet) with Funnel:
8787+8888+```bash
8989+./install-server.sh --funnel
9090+```
9191+9292+Notes:
9393+9494+- this does **not** replace or delete `install.sh`; it wraps server-hardening around it
9595+- normal updates still use `./update.sh` and keep your local `.env` values
9696+- if you already installed manually, this is still safe to run later
53975498## What This Project Does
5599···235279236280`update.sh`:
237281238238-- pulls latest code
282282+- stashes local uncommitted changes before pull and restores them after update
283283+- pulls latest code (supports non-`origin` remotes and detached-head recovery)
239284- installs dependencies
240240-- rebuilds native modules
285285+- rebuilds native modules when Node ABI changed
241286- builds server + web dashboard
242242-- restarts PM2 process when PM2 is available
243243-- preserves local `config.json` with backup/restore
287287+- restarts existing runtime for PM2 **or** nohup mode
288288+- preserves local `config.json` and `.env` with backup/restore
289289+290290+Useful update flags:
291291+292292+```bash
293293+./update.sh --no-restart
294294+./update.sh --skip-install --skip-build
295295+./update.sh --remote origin --branch main
296296+```
244297245298## Data, Config, and Security
246299···253306Security notes:
254307255308- first registered dashboard user is admin
309309+- after bootstrap, only admins can create additional dashboard users
310310+- users can sign in with username or email
311311+- non-admin users only see mappings they created by default
312312+- admins can grant fine-grained permissions (view all mappings, manage groups, queue backfills, run-now, etc.)
313313+- only admins can view or edit Twitter/AI provider credentials
314314+- admin user management never exposes other users' password hashes in the UI
256315- if `JWT_SECRET` is missing, server falls back to an insecure default; set your own secret in `.env`
257316- prefer Bluesky app passwords (not your full account password)
317317+318318+### Multi-User Access Control
319319+320320+- bootstrap account:
321321+ - the first account created through the web UI becomes admin
322322+ - open registration is automatically disabled after this
323323+- admin capabilities:
324324+ - create, edit, reset password, and delete dashboard users
325325+ - assign role (`admin` or `user`) and per-user permissions
326326+ - filter the Accounts page by creator to review each user's mappings
327327+- deleting a user:
328328+ - disables that user's mappings so crossposting stops
329329+ - leaves already-published Bluesky posts untouched
330330+- self-service security:
331331+ - every user can change their own password
332332+ - users can change their own email after password verification
258333259334## Development
260335
+494
install-server.sh
···11+#!/usr/bin/env bash
22+33+set -euo pipefail
44+55+APP_NAME="tweets-2-bsky"
66+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
77+cd "$SCRIPT_DIR"
88+99+ENV_FILE="$SCRIPT_DIR/.env"
1010+APP_PORT=""
1111+TS_HTTPS_PORT=""
1212+TS_AUTHKEY=""
1313+TS_HOSTNAME=""
1414+USE_FUNNEL=0
1515+INSTALL_ARGS=()
1616+1717+usage() {
1818+ cat <<'USAGE'
1919+Usage: ./install-server.sh [options]
2020+2121+Secure Linux VPS install for Tailscale-first access:
2222+ - Runs regular app installer
2323+ - Forces app bind to localhost only (HOST=127.0.0.1)
2424+ - Installs and starts Tailscale if needed
2525+ - Publishes app through Tailscale Serve (HTTPS on tailnet)
2626+2727+Options:
2828+ --port <number> App port (default: 3000; auto-adjusts if already in use)
2929+ --https-port <number> Tailscale HTTPS serve port (auto-select if omitted)
3030+ --auth-key <key> Tailscale auth key for non-interactive login
3131+ --hostname <name> Optional Tailscale device hostname
3232+ --funnel Also enable Tailscale Funnel (public internet)
3333+3434+Install passthrough options (forwarded to ./install.sh):
3535+ --no-start
3636+ --start-only
3737+ --pm2
3838+ --nohup
3939+ --skip-install
4040+ --skip-build
4141+4242+ -h, --help Show this help
4343+USAGE
4444+}
4545+4646+require_command() {
4747+ local command_name="$1"
4848+ if ! command -v "$command_name" >/dev/null 2>&1; then
4949+ echo "Required command not found: $command_name"
5050+ exit 1
5151+ fi
5252+}
5353+5454+is_valid_port() {
5555+ local candidate="$1"
5656+ [[ "$candidate" =~ ^[0-9]+$ ]] || return 1
5757+ (( candidate >= 1 && candidate <= 65535 ))
5858+}
5959+6060+is_local_port_free() {
6161+ local port="$1"
6262+ node -e '
6363+const net = require("node:net");
6464+const port = Number(process.argv[1]);
6565+const server = net.createServer();
6666+server.once("error", () => process.exit(1));
6767+server.once("listening", () => server.close(() => process.exit(0)));
6868+server.listen(port, "127.0.0.1");
6969+' "$port" >/dev/null 2>&1
7070+}
7171+7272+find_next_free_local_port() {
7373+ local start_port="$1"
7474+ local port
7575+ for port in $(seq "$start_port" 65535); do
7676+ if is_local_port_free "$port"; then
7777+ printf '%s\n' "$port"
7878+ return 0
7979+ fi
8080+ done
8181+ return 1
8282+}
8383+8484+ensure_linux() {
8585+ if [[ "$(uname -s)" != "Linux" ]]; then
8686+ echo "install-server.sh currently supports Linux only."
8787+ echo "Use ./install.sh on macOS or other environments."
8888+ exit 1
8989+ fi
9090+}
9191+9292+ensure_sudo() {
9393+ if [[ "$(id -u)" -eq 0 ]]; then
9494+ return
9595+ fi
9696+9797+ if ! command -v sudo >/dev/null 2>&1; then
9898+ echo "sudo is required to install and configure Tailscale on this host."
9999+ exit 1
100100+ fi
101101+}
102102+103103+run_as_root() {
104104+ if [[ "$(id -u)" -eq 0 ]]; then
105105+ "$@"
106106+ return
107107+ fi
108108+ sudo "$@"
109109+}
110110+111111+get_env_value() {
112112+ local key="$1"
113113+ if [[ ! -f "$ENV_FILE" ]]; then
114114+ return 0
115115+ fi
116116+ local line
117117+ line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
118118+ if [[ -z "$line" ]]; then
119119+ return 0
120120+ fi
121121+ printf '%s\n' "${line#*=}"
122122+}
123123+124124+upsert_env_value() {
125125+ local key="$1"
126126+ local value="$2"
127127+ touch "$ENV_FILE"
128128+ local tmp_file
129129+ tmp_file="$(mktemp)"
130130+ awk -v key="$key" -v value="$value" '
131131+ BEGIN { updated = 0 }
132132+ $0 ~ ("^" key "=") {
133133+ print key "=" value
134134+ updated = 1
135135+ next
136136+ }
137137+ { print }
138138+ END {
139139+ if (!updated) {
140140+ print key "=" value
141141+ }
142142+ }
143143+ ' "$ENV_FILE" > "$tmp_file"
144144+ mv "$tmp_file" "$ENV_FILE"
145145+}
146146+147147+ensure_local_only_env() {
148148+ local configured_port
149149+ configured_port="$(get_env_value PORT)"
150150+ if [[ -n "$configured_port" && -z "${APP_PORT:-}" ]]; then
151151+ APP_PORT="$configured_port"
152152+ fi
153153+ if [[ -z "${APP_PORT:-}" ]]; then
154154+ APP_PORT="3000"
155155+ fi
156156+157157+ if ! is_valid_port "$APP_PORT"; then
158158+ echo "Invalid port: $APP_PORT"
159159+ exit 1
160160+ fi
161161+162162+ if ! is_local_port_free "$APP_PORT"; then
163163+ local requested_port="$APP_PORT"
164164+ APP_PORT="$(find_next_free_local_port "$APP_PORT" || true)"
165165+ if [[ -z "$APP_PORT" ]]; then
166166+ echo "Could not find a free local port for the app."
167167+ exit 1
168168+ fi
169169+ echo "⚠️ App port ${requested_port} is already in use. Using ${APP_PORT} instead."
170170+ fi
171171+172172+ upsert_env_value PORT "$APP_PORT"
173173+ upsert_env_value HOST "127.0.0.1"
174174+}
175175+176176+run_app_install() {
177177+ local install_cmd=(bash "$SCRIPT_DIR/install.sh" --port "$APP_PORT")
178178+ install_cmd+=("${INSTALL_ARGS[@]}")
179179+ "${install_cmd[@]}"
180180+}
181181+182182+install_tailscale_if_needed() {
183183+ if command -v tailscale >/dev/null 2>&1; then
184184+ echo "✅ Tailscale already installed."
185185+ return
186186+ fi
187187+188188+ echo "📦 Installing Tailscale..."
189189+ if command -v curl >/dev/null 2>&1; then
190190+ run_as_root bash -c 'curl -fsSL https://tailscale.com/install.sh | sh'
191191+ elif command -v wget >/dev/null 2>&1; then
192192+ run_as_root bash -c 'wget -qO- https://tailscale.com/install.sh | sh'
193193+ else
194194+ echo "Need curl or wget to install Tailscale automatically."
195195+ exit 1
196196+ fi
197197+198198+ if ! command -v tailscale >/dev/null 2>&1; then
199199+ echo "Tailscale installation did not complete successfully."
200200+ exit 1
201201+ fi
202202+}
203203+204204+ensure_tailscaled_running() {
205205+ echo "🔧 Ensuring tailscaled is running..."
206206+ if command -v systemctl >/dev/null 2>&1; then
207207+ run_as_root systemctl enable --now tailscaled
208208+ return
209209+ fi
210210+211211+ if command -v rc-service >/dev/null 2>&1; then
212212+ run_as_root rc-service tailscaled start || true
213213+ if command -v rc-update >/dev/null 2>&1; then
214214+ run_as_root rc-update add tailscaled default || true
215215+ fi
216216+ return
217217+ fi
218218+219219+ if command -v service >/dev/null 2>&1; then
220220+ run_as_root service tailscaled start || true
221221+ return
222222+ fi
223223+224224+ echo "Could not detect init system to start tailscaled automatically."
225225+ echo "Please start tailscaled manually, then re-run this script."
226226+ exit 1
227227+}
228228+229229+ensure_tailscale_connected() {
230230+ if run_as_root tailscale ip -4 >/dev/null 2>&1; then
231231+ echo "✅ Tailscale is already connected."
232232+ return
233233+ fi
234234+235235+ echo "🔐 Connecting this host to Tailscale..."
236236+ local up_cmd=(tailscale up)
237237+238238+ if [[ -n "$TS_AUTHKEY" ]]; then
239239+ up_cmd+=(--authkey "$TS_AUTHKEY")
240240+ fi
241241+242242+ if [[ -n "$TS_HOSTNAME" ]]; then
243243+ up_cmd+=(--hostname "$TS_HOSTNAME")
244244+ fi
245245+246246+ if ! run_as_root "${up_cmd[@]}"; then
247247+ echo "Failed to connect to Tailscale."
248248+ if [[ -z "$TS_AUTHKEY" ]]; then
249249+ echo "Tip: provide --auth-key for non-interactive server setup."
250250+ fi
251251+ exit 1
252252+ fi
253253+}
254254+255255+get_used_tailscale_https_ports() {
256256+ local json
257257+ json="$(run_as_root tailscale serve status --json 2>/dev/null || true)"
258258+ if [[ -z "$json" || "$json" == "{}" ]]; then
259259+ return 0
260260+ fi
261261+262262+ printf '%s' "$json" | node -e '
263263+const fs = require("node:fs");
264264+try {
265265+ const data = JSON.parse(fs.readFileSync(0, "utf8"));
266266+ const sets = [data?.Web, data?.web, data?.TCP, data?.tcp];
267267+ const used = new Set();
268268+ for (const obj of sets) {
269269+ if (!obj || typeof obj !== "object") continue;
270270+ for (const key of Object.keys(obj)) {
271271+ if (/^\d+$/.test(key)) used.add(Number(key));
272272+ }
273273+ }
274274+ process.stdout.write([...used].sort((a, b) => a - b).join("\n"));
275275+} catch {}
276276+'
277277+}
278278+279279+pick_tailscale_https_port() {
280280+ local preferred="$1"
281281+ local allow_used_preferred="${2:-0}"
282282+ local used_ports
283283+ used_ports="$(get_used_tailscale_https_ports || true)"
284284+285285+ local is_used=0
286286+ if [[ -n "$preferred" ]]; then
287287+ if ! is_valid_port "$preferred"; then
288288+ echo "Invalid Tailscale HTTPS port: $preferred"
289289+ exit 1
290290+ fi
291291+ if [[ -z "$used_ports" ]] || ! grep -qx "$preferred" <<<"$used_ports"; then
292292+ printf '%s\n' "$preferred"
293293+ return 0
294294+ fi
295295+ if [[ "$allow_used_preferred" -eq 1 ]]; then
296296+ printf '%s\n' "$preferred"
297297+ return 0
298298+ fi
299299+ is_used=1
300300+ fi
301301+302302+ local candidate
303303+ for candidate in 443 8443 9443; do
304304+ if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then
305305+ printf '%s\n' "$candidate"
306306+ return 0
307307+ fi
308308+ done
309309+310310+ for candidate in $(seq 10000 65535); do
311311+ if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then
312312+ printf '%s\n' "$candidate"
313313+ return 0
314314+ fi
315315+ done
316316+317317+ if [[ "$is_used" -eq 1 ]]; then
318318+ echo "No free Tailscale HTTPS serve port available (preferred port is already used)."
319319+ else
320320+ echo "No free Tailscale HTTPS serve port available."
321321+ fi
322322+ exit 1
323323+}
324324+325325+configure_tailscale_serve() {
326326+ local preferred_https_port="$TS_HTTPS_PORT"
327327+ local preferred_from_saved=0
328328+ local saved_https_port
329329+ saved_https_port="$(get_env_value TAILSCALE_HTTPS_PORT)"
330330+ if [[ -z "$preferred_https_port" && -n "$saved_https_port" ]]; then
331331+ preferred_https_port="$saved_https_port"
332332+ preferred_from_saved=1
333333+ fi
334334+335335+ TS_HTTPS_PORT="$(pick_tailscale_https_port "$preferred_https_port" "$preferred_from_saved")"
336336+ upsert_env_value TAILSCALE_HTTPS_PORT "$TS_HTTPS_PORT"
337337+338338+ if [[ -n "$preferred_https_port" && "$preferred_https_port" != "$TS_HTTPS_PORT" ]]; then
339339+ echo "⚠️ Tailscale HTTPS port ${preferred_https_port} is already used. Using ${TS_HTTPS_PORT}."
340340+ fi
341341+342342+ echo "🌐 Configuring Tailscale Serve (HTTPS ${TS_HTTPS_PORT} -> localhost:${APP_PORT})..."
343343+ if ! run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then
344344+ run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}"
345345+ fi
346346+347347+ if [[ "$USE_FUNNEL" -eq 1 ]]; then
348348+ echo "⚠️ Enabling Tailscale Funnel (public internet exposure)..."
349349+ if ! run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then
350350+ run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}"
351351+ fi
352352+ fi
353353+}
354354+355355+get_tailscale_dns_name() {
356356+ local json
357357+ json="$(run_as_root tailscale status --json 2>/dev/null || true)"
358358+ if [[ -z "$json" ]]; then
359359+ return 0
360360+ fi
361361+362362+ printf '%s' "$json" | node -e '
363363+const fs = require("node:fs");
364364+try {
365365+ const data = JSON.parse(fs.readFileSync(0, "utf8"));
366366+ const dnsName = typeof data?.Self?.DNSName === "string" ? data.Self.DNSName : "";
367367+ process.stdout.write(dnsName.replace(/\.$/, ""));
368368+} catch {}
369369+'
370370+}
371371+372372+get_tailscale_ipv4() {
373373+ run_as_root tailscale ip -4 2>/dev/null | head -n 1 || true
374374+}
375375+376376+print_summary() {
377377+ local dns_name
378378+ dns_name="$(get_tailscale_dns_name)"
379379+ local ts_ip
380380+ ts_ip="$(get_tailscale_ipv4)"
381381+ local final_url=""
382382+ local port_suffix=""
383383+ if [[ "$TS_HTTPS_PORT" != "443" ]]; then
384384+ port_suffix=":${TS_HTTPS_PORT}"
385385+ fi
386386+ if [[ -n "$dns_name" ]]; then
387387+ final_url="https://${dns_name}${port_suffix}"
388388+ elif [[ -n "$ts_ip" ]]; then
389389+ final_url="https://${ts_ip}${port_suffix}"
390390+ fi
391391+392392+ echo ""
393393+ echo "Setup complete for Linux server mode."
394394+ echo ""
395395+ echo "App binding:"
396396+ echo " HOST=127.0.0.1 (local-only)"
397397+ echo " PORT=${APP_PORT}"
398398+ echo ""
399399+ echo "Local checks on server:"
400400+ echo " http://127.0.0.1:${APP_PORT}"
401401+ echo ""
402402+ echo "Tailnet access:"
403403+ if [[ -n "$final_url" ]]; then
404404+ echo " ${final_url}"
405405+ echo ""
406406+ echo "✅ It will be accessible on ${final_url} wherever that person is authenticated on Tailscale."
407407+ else
408408+ echo " Run: sudo tailscale status"
409409+ fi
410410+411411+ if [[ "$USE_FUNNEL" -eq 1 ]]; then
412412+ echo ""
413413+ echo "Public access is enabled via Funnel."
414414+ else
415415+ echo ""
416416+ echo "Public internet exposure is disabled."
417417+ fi
418418+419419+ echo ""
420420+ echo "Useful commands:"
421421+ echo " ./install.sh --status"
422422+ echo " sudo tailscale serve status"
423423+ if [[ "$USE_FUNNEL" -eq 1 ]]; then
424424+ echo " sudo tailscale funnel status"
425425+ fi
426426+}
427427+428428+while [[ $# -gt 0 ]]; do
429429+ case "$1" in
430430+ --port)
431431+ if [[ $# -lt 2 ]]; then
432432+ echo "Missing value for --port"
433433+ exit 1
434434+ fi
435435+ APP_PORT="$2"
436436+ shift
437437+ ;;
438438+ --https-port)
439439+ if [[ $# -lt 2 ]]; then
440440+ echo "Missing value for --https-port"
441441+ exit 1
442442+ fi
443443+ TS_HTTPS_PORT="$2"
444444+ shift
445445+ ;;
446446+ --auth-key)
447447+ if [[ $# -lt 2 ]]; then
448448+ echo "Missing value for --auth-key"
449449+ exit 1
450450+ fi
451451+ TS_AUTHKEY="$2"
452452+ shift
453453+ ;;
454454+ --hostname)
455455+ if [[ $# -lt 2 ]]; then
456456+ echo "Missing value for --hostname"
457457+ exit 1
458458+ fi
459459+ TS_HOSTNAME="$2"
460460+ shift
461461+ ;;
462462+ --funnel)
463463+ USE_FUNNEL=1
464464+ ;;
465465+ --pm2|--nohup|--skip-install|--skip-build|--no-start|--start-only)
466466+ INSTALL_ARGS+=("$1")
467467+ ;;
468468+ -h|--help)
469469+ usage
470470+ exit 0
471471+ ;;
472472+ *)
473473+ echo "Unknown option: $1"
474474+ usage
475475+ exit 1
476476+ ;;
477477+ esac
478478+ shift
479479+done
480480+481481+ensure_linux
482482+ensure_sudo
483483+require_command bash
484484+require_command node
485485+require_command npm
486486+require_command git
487487+488488+ensure_local_only_env
489489+run_app_install
490490+install_tailscale_if_needed
491491+ensure_tailscaled_running
492492+ensure_tailscale_connected
493493+configure_tailscale_serve
494494+print_summary
+228-46
install.sh
···33set -euo pipefail
4455APP_NAME="tweets-2-bsky"
66+LEGACY_APP_NAME="twitter-mirror"
67SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
78cd "$SCRIPT_DIR"
89···1011RUNTIME_DIR="$SCRIPT_DIR/data/runtime"
1112PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid"
1213LOG_FILE="$RUNTIME_DIR/${APP_NAME}.log"
1414+LOCK_DIR="$RUNTIME_DIR/.install.lock"
13151416ACTION="install"
1517DO_INSTALL=1
1618DO_BUILD=1
1719DO_START=1
2020+DO_NATIVE_REBUILD=1
1821RUNNER="auto"
1922PORT_OVERRIDE=""
2323+HOST_OVERRIDE=""
2024APP_PORT=""
2525+APP_HOST=""
2126ACTIVE_RUNNER=""
2227CREATED_JWT_SECRET=0
2328···27322833Default behavior:
2934 - Installs dependencies
3535+ - Rebuilds native modules if needed
3036 - Builds server + web app
3137 - Starts in the background (PM2 if installed, otherwise nohup)
3238 - Prints local web URL
33393440Options:
3535- --no-start Install/build only (do not start background process)
3636- --start-only Start background process only (skip install/build)
3737- --stop Stop background process (PM2 and/or nohup)
3838- --status Show background process status
3939- --pm2 Force PM2 runner
4040- --nohup Force nohup runner
4141- --port <number> Set or override PORT in .env
4242- --skip-install Skip npm install
4343- --skip-build Skip npm run build
4444- -h, --help Show this help
4141+ --no-start Install/build only (do not start background process)
4242+ --start-only Start background process only (skip install/build)
4343+ --stop Stop background process (PM2 and/or nohup)
4444+ --status Show background process status
4545+ --pm2 Force PM2 runner
4646+ --nohup Force nohup runner
4747+ --port <number> Set or override PORT in .env
4848+ --host <bind-host> Set or override HOST in .env (for example 127.0.0.1)
4949+ --skip-install Skip npm install
5050+ --skip-build Skip npm run build
5151+ --skip-native-rebuild Skip native-module compatibility rebuild checks
5252+ -h, --help Show this help
4553USAGE
4654}
4755···5967 (( candidate >= 1 && candidate <= 65535 ))
6068}
61697070+check_node_version() {
7171+ local node_major
7272+ node_major="$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0)"
7373+ if [[ "$node_major" -lt 22 ]]; then
7474+ echo "Node.js 22+ is required. Current: $(node -v 2>/dev/null || echo 'unknown')"
7575+ exit 1
7676+ fi
7777+}
7878+7979+acquire_lock() {
8080+ mkdir -p "$RUNTIME_DIR"
8181+ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
8282+ echo "Another install/update operation appears to be running."
8383+ echo "If this is stale, remove: $LOCK_DIR"
8484+ exit 1
8585+ fi
8686+}
8787+8888+release_lock() {
8989+ rmdir "$LOCK_DIR" >/dev/null 2>&1 || true
9090+}
9191+9292+cleanup() {
9393+ release_lock
9494+}
9595+6296get_env_value() {
6397 local key="$1"
6498 if [[ ! -f "$ENV_FILE" ]]; then
···115149 upsert_env_value PORT "$APP_PORT"
116150 fi
117151152152+ local existing_host
153153+ existing_host="$(get_env_value HOST)"
154154+ if [[ -n "$HOST_OVERRIDE" ]]; then
155155+ APP_HOST="$HOST_OVERRIDE"
156156+ upsert_env_value HOST "$APP_HOST"
157157+ elif [[ -n "$existing_host" ]]; then
158158+ APP_HOST="$existing_host"
159159+ else
160160+ APP_HOST="0.0.0.0"
161161+ fi
162162+118163 local existing_secret
119164 existing_secret="$(get_env_value JWT_SECRET)"
120165 if [[ -z "$existing_secret" ]]; then
···125170 fi
126171}
127172173173+ensure_node_modules_present() {
174174+ if [[ ! -d "$SCRIPT_DIR/node_modules" ]]; then
175175+ echo "node_modules not found. Run ./install.sh (without --start-only) first."
176176+ exit 1
177177+ fi
178178+}
179179+180180+native_module_compatible() {
181181+ 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
182182+}
183183+184184+run_native_rebuild() {
185185+ echo "Verifying native modules for Node $(node -v)..."
186186+187187+ if npm run rebuild:native; then
188188+ return 0
189189+ fi
190190+191191+ echo "rebuild:native failed. Falling back to direct better-sqlite3 rebuild..."
192192+ if npm rebuild better-sqlite3; then
193193+ return 0
194194+ fi
195195+196196+ npm rebuild better-sqlite3 --build-from-source
197197+}
198198+199199+ensure_native_compatibility() {
200200+ if [[ "$DO_NATIVE_REBUILD" -eq 0 ]]; then
201201+ return 0
202202+ fi
203203+204204+ if native_module_compatible; then
205205+ return 0
206206+ fi
207207+208208+ echo "Detected native module mismatch (likely from Node version change)."
209209+ run_native_rebuild
210210+211211+ if ! native_module_compatible; then
212212+ echo "Native module validation still failed after rebuild."
213213+ echo "Try reinstalling dependencies: rm -rf node_modules package-lock.json && npm install"
214214+ exit 1
215215+ fi
216216+}
217217+128218ensure_build_artifacts() {
129219 if [[ ! -f "$SCRIPT_DIR/dist/index.js" ]]; then
130220 echo "Build output not found (dist/index.js). Running build now."
···135225install_and_build() {
136226 if [[ "$DO_INSTALL" -eq 1 ]]; then
137227 echo "Installing dependencies"
138138- npm install
228228+ npm install --no-audit --no-fund
139229 fi
140230231231+ ensure_node_modules_present
232232+ ensure_native_compatibility
233233+141234 if [[ "$DO_BUILD" -eq 1 ]]; then
142235 echo "Building server and web app"
143236 npm run build
144237 fi
145238}
146239240240+pid_looks_like_app() {
241241+ local pid="$1"
242242+ local cmd
243243+ cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
244244+ [[ "$cmd" == *"dist/index.js"* || "$cmd" == *"npm start"* || "$cmd" == *"$APP_NAME"* ]]
245245+}
246246+247247+stop_pid_gracefully() {
248248+ local pid="$1"
249249+250250+ if ! kill -0 "$pid" >/dev/null 2>&1; then
251251+ return 0
252252+ fi
253253+254254+ kill "$pid" >/dev/null 2>&1 || true
255255+256256+ local attempt
257257+ for attempt in $(seq 1 20); do
258258+ if ! kill -0 "$pid" >/dev/null 2>&1; then
259259+ return 0
260260+ fi
261261+ sleep 0.5
262262+ done
263263+264264+ kill -9 "$pid" >/dev/null 2>&1 || true
265265+}
266266+147267stop_nohup_if_running() {
148268 if [[ ! -f "$PID_FILE" ]]; then
149269 return 1
···156276 return 1
157277 fi
158278159159- if kill -0 "$pid" >/dev/null 2>&1; then
160160- kill "$pid" >/dev/null 2>&1 || true
279279+ if ! kill -0 "$pid" >/dev/null 2>&1; then
161280 rm -f "$PID_FILE"
281281+ return 1
282282+ fi
283283+284284+ if ! pid_looks_like_app "$pid"; then
285285+ echo "PID file points to a non-app process. Removing stale PID file: $PID_FILE"
286286+ rm -f "$PID_FILE"
287287+ return 1
288288+ fi
289289+290290+ stop_pid_gracefully "$pid"
291291+ rm -f "$PID_FILE"
292292+ return 0
293293+}
294294+295295+stop_pm2_if_running() {
296296+ if ! command -v pm2 >/dev/null 2>&1; then
297297+ return 1
298298+ fi
299299+300300+ local stopped=0
301301+302302+ if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
303303+ pm2 delete "$APP_NAME" >/dev/null 2>&1 || true
304304+ stopped=1
305305+ fi
306306+307307+ if pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then
308308+ pm2 delete "$LEGACY_APP_NAME" >/dev/null 2>&1 || true
309309+ stopped=1
310310+ fi
311311+312312+ if [[ "$stopped" -eq 1 ]]; then
313313+ pm2 save >/dev/null 2>&1 || true
162314 return 0
163315 fi
164316165165- rm -f "$PID_FILE"
166317 return 1
167318}
168319···171322 stop_nohup_if_running >/dev/null 2>&1 || true
172323173324 echo "Starting with nohup"
174174- nohup npm start > "$LOG_FILE" 2>&1 &
325325+ nohup npm start >> "$LOG_FILE" 2>&1 &
175326 echo "$!" > "$PID_FILE"
176327177328 local pid
···180331 if ! kill -0 "$pid" >/dev/null 2>&1; then
181332 echo "Failed to start background process with nohup."
182333 echo "Check logs: $LOG_FILE"
334334+ tail -n 40 "$LOG_FILE" 2>/dev/null || true
183335 exit 1
184336 fi
185337}
186338187187-stop_pm2_if_running() {
188188- if ! command -v pm2 >/dev/null 2>&1; then
189189- return 1
190190- fi
191191-192192- if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
193193- pm2 delete "$APP_NAME" >/dev/null 2>&1 || true
194194- pm2 save >/dev/null 2>&1 || true
195195- return 0
196196- fi
197197-198198- return 1
199199-}
200200-201339start_with_pm2() {
202340 echo "Starting with PM2"
203341204204- if pm2 describe "twitter-mirror" >/dev/null 2>&1; then
205205- pm2 delete "twitter-mirror" >/dev/null 2>&1 || true
342342+ if pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then
343343+ pm2 delete "$LEGACY_APP_NAME" >/dev/null 2>&1 || true
206344 fi
207345208346 if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
209347 pm2 restart "$APP_NAME" --update-env >/dev/null 2>&1
210348 else
211211- pm2 start dist/index.js --name "$APP_NAME" --update-env >/dev/null 2>&1
349349+ pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env >/dev/null 2>&1
212350 fi
351351+213352 pm2 save >/dev/null 2>&1 || true
214353}
215354···241380}
242381243382wait_for_web() {
244244- if ! command -v curl >/dev/null 2>&1; then
245245- return 0
246246- fi
247247-248383 local url="http://127.0.0.1:${APP_PORT}"
249384 local attempt
250250- for ((attempt = 1; attempt <= 30; attempt++)); do
251251- if curl -fsS "$url" >/dev/null 2>&1; then
252252- return 0
385385+386386+ for attempt in $(seq 1 30); do
387387+ if command -v curl >/dev/null 2>&1; then
388388+ if curl -fsS "$url" >/dev/null 2>&1; then
389389+ return 0
390390+ fi
391391+ else
392392+ 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
393393+ return 0
394394+ fi
253395 fi
254396 sleep 1
255397 done
···260402print_access_info() {
261403 echo ""
262404 echo "Setup complete."
263263- echo "Web app URL: http://localhost:${APP_PORT}"
405405+ echo "Bind host: ${APP_HOST}"
406406+ echo "Web app URL (local): http://localhost:${APP_PORT}"
264407265408 if [[ "$CREATED_JWT_SECRET" -eq 1 ]]; then
266409 echo "Generated JWT_SECRET in .env"
267410 fi
268411412412+ if [[ "$APP_HOST" == "127.0.0.1" || "$APP_HOST" == "::1" || "$APP_HOST" == "localhost" ]]; then
413413+ echo "Access scope: local-only bind (use reverse proxy or Tailscale for remote access)"
414414+ else
415415+ echo "Access scope: network-accessible bind"
416416+ fi
417417+269418 if [[ "$ACTIVE_RUNNER" == "pm2" ]]; then
270419 echo "Process manager: PM2"
271420 echo "Status: pm2 status $APP_NAME"
272421 echo "Logs: pm2 logs $APP_NAME"
273273- echo "Stop: pm2 delete $APP_NAME"
422422+ echo "Stop: ./install.sh --stop"
274423 elif [[ "$ACTIVE_RUNNER" == "nohup" ]]; then
275424 echo "Process manager: nohup"
276425 echo "PID file: $PID_FILE"
···288437show_status() {
289438 local found=0
290439 local configured_port
440440+ local configured_host
291441 configured_port="$(get_env_value PORT)"
442442+ configured_host="$(get_env_value HOST)"
443443+292444 if [[ -n "$configured_port" ]]; then
293445 echo "Configured PORT: $configured_port"
294446 fi
447447+ if [[ -n "$configured_host" ]]; then
448448+ echo "Configured HOST: $configured_host"
449449+ fi
295450296296- if command -v pm2 >/dev/null 2>&1 && pm2 describe "$APP_NAME" >/dev/null 2>&1; then
297297- found=1
298298- echo "PM2 process is running:"
299299- pm2 status "$APP_NAME"
451451+ if command -v pm2 >/dev/null 2>&1; then
452452+ if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
453453+ found=1
454454+ echo "PM2 process is running: $APP_NAME"
455455+ pm2 status "$APP_NAME"
456456+ elif pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then
457457+ found=1
458458+ echo "PM2 process is running: $LEGACY_APP_NAME"
459459+ pm2 status "$LEGACY_APP_NAME"
460460+ fi
300461 fi
301462302463 if [[ -f "$PID_FILE" ]]; then
···318479319480stop_all() {
320481 local stopped=0
482482+321483 if stop_pm2_if_running; then
322484 stopped=1
323323- echo "Stopped PM2 process: $APP_NAME"
485485+ echo "Stopped PM2 process(es)."
324486 fi
325487326488 if stop_nohup_if_running; then
···364526 PORT_OVERRIDE="$2"
365527 shift
366528 ;;
529529+ --host)
530530+ if [[ $# -lt 2 ]]; then
531531+ echo "Missing value for --host"
532532+ exit 1
533533+ fi
534534+ HOST_OVERRIDE="$2"
535535+ shift
536536+ ;;
367537 --skip-install)
368538 DO_INSTALL=0
369539 ;;
370540 --skip-build)
371541 DO_BUILD=0
542542+ ;;
543543+ --skip-native-rebuild)
544544+ DO_NATIVE_REBUILD=0
372545 ;;
373546 -h|--help)
374547 usage
···385558386559case "$ACTION" in
387560 stop)
561561+ acquire_lock
562562+ trap cleanup EXIT
388563 stop_all
389564 exit 0
390565 ;;
···396571397572require_command node
398573require_command npm
574574+check_node_version
575575+576576+acquire_lock
577577+trap cleanup EXIT
399578400579ensure_env_defaults
401580402581if [[ "$ACTION" == "install" ]]; then
403582 install_and_build
583583+else
584584+ ensure_node_modules_present
585585+ ensure_native_compatibility
404586fi
405587406588if [[ "$DO_START" -eq 0 ]]; then