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.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(docker): persist app data across container restarts

jack 1110d9b2 91da18ac

+98 -21
+1
Dockerfile
··· 31 31 ENV NODE_ENV=production \ 32 32 HOST=0.0.0.0 \ 33 33 PORT=3000 \ 34 + TWEETS2BSKY_DATA_DIR=/app/data \ 34 35 SCHEDULED_ACCOUNT_TIMEOUT_MS=480000 \ 35 36 CHROME_BIN=/usr/bin/chromium \ 36 37 PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
+12 -1
README.md
··· 9 9 10 10 Prerequisite: Docker Desktop (Windows/macOS) or Docker Engine (Linux). On Windows, use Docker Desktop in Linux container mode. 11 11 12 + Prefer using the included compose file so the named volume is always attached: 13 + 14 + ```bash 15 + docker compose up -d 16 + ``` 17 + 12 18 ### 1) Run the latest image 13 19 14 20 macOS/Linux (bash): ··· 30 36 31 37 Open `http://localhost:3000`. 32 38 39 + Important: keep `-v tweets2bsky_data:/app/data` (or an equivalent bind mount). Without a persistent volume, mappings and history are lost when the container is recreated. 40 + 33 41 If port `3000` is already in use, change only the first port (example: `-p 3001:3000`). 34 42 35 43 ### 2) Complete first-time setup ··· 121 129 122 130 The container aims for feature parity with normal installs while giving one-command startup. 123 131 132 + Tip: the repository includes `docker-compose.yml` with a named `tweets2bsky_data` volume, so `docker compose up -d` is the safest default. 133 + 124 134 ### 1) Pull and run (recommended) 125 135 126 136 After publishing an image (see [Publishing](#publishing-multi-platform-images-linuxamd64--linuxarm64)), run: ··· 178 188 - `CORS_ALLOWED_ORIGINS` 179 189 - `BSKY_APPVIEW_URL` (optional override) 180 190 - `SCHEDULED_ACCOUNT_TIMEOUT_MS` (default `480000` / 8 minutes, forces a skip when one source account hangs during scheduled checks) 191 + - `TWEETS2BSKY_DATA_DIR` (default `/app/data` in Docker; keep aligned with your mounted data volume path) 181 192 182 193 ### 4) Persistent data inside Docker 183 194 ··· 187 198 - `/app/data/database.sqlite` 188 199 - `/app/data/.jwt-secret` 189 200 190 - Note: inside the container, `/app/config.json` is linked to `/app/data/config.json` so one volume preserves everything important. 201 + The app reads persistent files from `TWEETS2BSKY_DATA_DIR` (defaults to `/app/data` in Docker), so a single mounted volume preserves everything important. 191 202 192 203 ### 5) CLI usage in container 193 204
+6
TROUBLESHOOTING.md
··· 120 120 docker run -d --name tweets-2-bsky -p 3000:3000 -v tweets2bsky_data:/app/data ghcr.io/j4ckxyz/tweets-2-bsky:latest 121 121 ``` 122 122 123 + The container stores persistent state under `TWEETS2BSKY_DATA_DIR` (default `/app/data`). If you mount a different path, set that env var to match: 124 + 125 + ```bash 126 + docker run -d --name tweets-2-bsky -p 3000:3000 -v /host/path:/persist -e TWEETS2BSKY_DATA_DIR=/persist ghcr.io/j4ckxyz/tweets-2-bsky:latest 127 + ``` 128 + 123 129 ### Docker: updating image 124 130 In Docker mode, update by pulling a newer image and recreating the container with the same volume. 125 131 `/api/update` / `update.sh` are source-install workflows.
+15
docker-compose.yml
··· 1 + services: 2 + tweets-2-bsky: 3 + image: j4ckxyz/tweets-2-bsky:latest 4 + container_name: tweets-2-bsky 5 + restart: unless-stopped 6 + ports: 7 + - "3000:3000" 8 + environment: 9 + TWEETS2BSKY_DATA_DIR: /app/data 10 + volumes: 11 + - tweets2bsky_data:/app/data 12 + 13 + volumes: 14 + tweets2bsky_data: 15 + name: tweets2bsky_data
+30 -5
src/config-manager.ts
··· 1 1 import { randomUUID } from 'node:crypto'; 2 2 import fs from 'node:fs'; 3 - import path from 'node:path'; 4 - import { fileURLToPath } from 'node:url'; 3 + import { 4 + ACTIVE_CONFIG_FILE, 5 + LEGACY_CONFIG_FILE, 6 + USING_EXTERNAL_DATA_DIR, 7 + } from './storage-paths.js'; 8 + 9 + const CONFIG_FILE = ACTIVE_CONFIG_FILE; 10 + let configPathInitialized = false; 11 + 12 + function ensureConfigPathReady(): void { 13 + if (configPathInitialized) { 14 + return; 15 + } 16 + configPathInitialized = true; 5 17 6 - const __filename = fileURLToPath(import.meta.url); 7 - const __dirname = path.dirname(__filename); 18 + if (!USING_EXTERNAL_DATA_DIR || fs.existsSync(CONFIG_FILE) || !fs.existsSync(LEGACY_CONFIG_FILE)) { 19 + return; 20 + } 8 21 9 - const CONFIG_FILE = path.join(__dirname, '..', 'config.json'); 22 + try { 23 + fs.copyFileSync(LEGACY_CONFIG_FILE, CONFIG_FILE); 24 + console.log(`📦 Migrated config from ${LEGACY_CONFIG_FILE} to ${CONFIG_FILE}.`); 25 + } catch (error) { 26 + console.warn( 27 + `⚠️ Failed to migrate legacy config from ${LEGACY_CONFIG_FILE} to ${CONFIG_FILE}: ${(error as Error).message}`, 28 + ); 29 + } 30 + } 10 31 11 32 export interface TwitterConfig { 12 33 authToken: string; ··· 434 455 }; 435 456 436 457 export function getConfig(): AppConfig { 458 + ensureConfigPathReady(); 459 + 437 460 if (!fs.existsSync(CONFIG_FILE)) { 438 461 return { ...DEFAULT_CONFIG }; 439 462 } ··· 455 478 } 456 479 457 480 export function saveConfig(config: AppConfig): void { 481 + ensureConfigPathReady(); 482 + 458 483 const normalizedConfig = normalizeConfigShape(config); 459 484 writeConfigFile(normalizedConfig); 460 485 }
+1 -13
src/db.ts
··· 1 - import fs from 'node:fs'; 2 - import path from 'node:path'; 3 - import { fileURLToPath } from 'node:url'; 1 + import { DB_PATH } from './storage-paths.js'; 4 2 5 3 interface DbStatement { 6 4 get: (...params: any[]) => unknown; ··· 14 12 transaction: <T extends (...args: any[]) => any>(fn: T) => T; 15 13 pragma?: (sql: string) => unknown; 16 14 } 17 - 18 - const __filename = fileURLToPath(import.meta.url); 19 - const __dirname = path.dirname(__filename); 20 - 21 - const DB_DIR = path.join(__dirname, '..', 'data'); 22 - if (!fs.existsSync(DB_DIR)) { 23 - fs.mkdirSync(DB_DIR); 24 - } 25 - 26 - const DB_PATH = path.join(DB_DIR, 'database.sqlite'); 27 15 28 16 const db: DbLike = await (async () => { 29 17 if (typeof process.versions.bun === 'string') {
+1 -2
src/server.ts
··· 31 31 syncBlueskyProfileFromTwitter, 32 32 validateBlueskyCredentials, 33 33 } from './profile-mirror.js'; 34 + import { JWT_SECRET_FILE_PATH, UPDATE_LOG_DIR } from './storage-paths.js'; 34 35 35 36 const __filename = fileURLToPath(import.meta.url); 36 37 const __dirname = path.dirname(__filename); ··· 39 40 const PORT = Number(process.env.PORT) || 3000; 40 41 const HOST = (process.env.HOST || process.env.BIND_HOST || '0.0.0.0').trim() || '0.0.0.0'; 41 42 const APP_ROOT_DIR = path.join(__dirname, '..'); 42 - const JWT_SECRET_FILE_PATH = path.join(APP_ROOT_DIR, 'data', '.jwt-secret'); 43 43 const jwtSecretFromEnv = process.env.JWT_SECRET?.trim(); 44 44 const JWT_EXPIRES_IN = ((process.env.JWT_EXPIRES_IN || '30d').trim() || '30d') as SignOptions['expiresIn']; 45 45 const WEB_DIST_DIR = path.join(APP_ROOT_DIR, 'web', 'dist'); 46 46 const LEGACY_PUBLIC_DIR = path.join(APP_ROOT_DIR, 'public'); 47 47 const PACKAGE_JSON_PATH = path.join(APP_ROOT_DIR, 'package.json'); 48 48 const UPDATE_SCRIPT_PATH = path.join(APP_ROOT_DIR, 'update.sh'); 49 - const UPDATE_LOG_DIR = path.join(APP_ROOT_DIR, 'data'); 50 49 const staticAssetsDir = fs.existsSync(path.join(WEB_DIST_DIR, 'index.html')) ? WEB_DIST_DIR : LEGACY_PUBLIC_DIR; 51 50 const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app'; 52 51 const POST_VIEW_CACHE_TTL_MS = 60_000;
+32
src/storage-paths.ts
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = path.dirname(__filename); 7 + 8 + const APP_ROOT_DIR = path.join(__dirname, '..'); 9 + const DEFAULT_DATA_DIR = path.join(APP_ROOT_DIR, 'data'); 10 + 11 + function resolveConfiguredDataDir(rawValue: string | undefined): string | undefined { 12 + const value = rawValue?.trim(); 13 + if (!value) { 14 + return undefined; 15 + } 16 + return path.isAbsolute(value) ? value : path.resolve(APP_ROOT_DIR, value); 17 + } 18 + 19 + const configuredDataDir = resolveConfiguredDataDir(process.env.TWEETS2BSKY_DATA_DIR || process.env.APP_DATA_DIR); 20 + 21 + export const DATA_DIR = configuredDataDir ?? DEFAULT_DATA_DIR; 22 + export const USING_EXTERNAL_DATA_DIR = Boolean(configuredDataDir); 23 + 24 + export const LEGACY_CONFIG_FILE = path.join(APP_ROOT_DIR, 'config.json'); 25 + export const DATA_CONFIG_FILE = path.join(DATA_DIR, 'config.json'); 26 + export const ACTIVE_CONFIG_FILE = USING_EXTERNAL_DATA_DIR ? DATA_CONFIG_FILE : LEGACY_CONFIG_FILE; 27 + 28 + export const DB_PATH = path.join(DATA_DIR, 'database.sqlite'); 29 + export const JWT_SECRET_FILE_PATH = path.join(DATA_DIR, '.jwt-secret'); 30 + export const UPDATE_LOG_DIR = DATA_DIR; 31 + 32 + fs.mkdirSync(DATA_DIR, { recursive: true });