#!/bin/sh # Proof of Work (proof-of-work) — StartOS 0.4 container entrypoint. # # Responsibilities (in order): # 1. Ensure the persistent /data directory exists. # 2. First-boot fallback ONLY: if /data is empty (no app.db, no # .seeded marker) AND a fallback DB exists at # /app/prisma/data/app.db, copy it into /data. This handles # brand-new sideloads on a host that has never had proof-of-work # installed before. Existing installs ALWAYS skip this branch # because /data/app.db is already present. # (v1.0.0:1 had a second branch that copied a baked cutover seed # from /app/seed/data/app.db. v1.0.0:3 stripped both the COPY of # that seed in the Dockerfile and the branch that copied it here, # now that the cutover from `workout-log` is verified done.) # 3. Run idempotent compat ALTERs for columns added after older snapshots. # No-ops on hosts whose schema is already current. # 4. Ensure the curated exercise library is present for every user. New # maintainer-shipped exercises appear on every boot (INSERT OR IGNORE # keyed on (userId, name); never overwrites a user's own exercises). # 5. Exec the Next.js standalone server as PID 1 (under dumb-init). # # Every branch logs to stderr so it is visible in StartOS -> Logs. set -eu DATA_DIR="${WORKOUT_DATA_DIR:-/data}" DB_PATH="${WORKOUT_DB_PATH:-$DATA_DIR/app.db}" FALLBACK_SEED_DB_PATH="${WORKOUT_FALLBACK_SEED_DB_PATH:-/app/prisma/data/app.db}" LIBRARY_JSON_PATH="${WORKOUT_LIBRARY_JSON_PATH:-/app/prisma/exercises.seed.json}" log() { # write to stderr so StartOS log viewer surfaces it immediately printf '[entrypoint] %s\n' "$*" 1>&2 } mkdir -p "$DATA_DIR" # ----------------------------------------------------------------------------- # Step 1 — first-boot fallback. NEVER overwrites an existing app.db. # Skipped on every restart of an installed host because /data/app.db # already exists. # ----------------------------------------------------------------------------- if [ ! -f "$DB_PATH" ] && [ ! -f "$DATA_DIR/.seeded" ]; then if [ -f "$FALLBACK_SEED_DB_PATH" ]; then log "no $DB_PATH and no .seeded marker; copying empty-schema fallback from $FALLBACK_SEED_DB_PATH" cp "$FALLBACK_SEED_DB_PATH" "$DB_PATH" date -u +"seeded from empty-schema fallback at %Y-%m-%dT%H:%M:%SZ" > "$DATA_DIR/.seeded" else log "no $DB_PATH and no fallback DB; creating empty $DB_PATH" touch "$DB_PATH" fi else log "$DB_PATH already present; live data is the source of truth" if [ -f "$DATA_DIR/.seeded" ]; then log "found .seeded: $(cat "$DATA_DIR/.seeded")" fi fi # ----------------------------------------------------------------------------- # Step 2 — idempotent compat ALTERs (safety net for older snapshots). # No-ops on hosts whose schema is already current. # ----------------------------------------------------------------------------- if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|customMetrics|"; then log "adding missing column SetLog.customMetrics" sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;" fi if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then log "adding missing column Workout.deletedAt" sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;" fi # Multi-user support shipped in v1.0.0:1: User.isAdmin column + # InstanceSettings singleton. New install: seed.ts creates both. Upgrade # from a snapshot pulled off the legacy `workout-log` package: this block # adds them in place, then promotes the oldest user to admin so the # in-app admin Settings panel + change-credentials action keep working. if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|isAdmin|"; then log "adding missing column User.isAdmin (default 0)" sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN isAdmin INTEGER NOT NULL DEFAULT 0;" log "promoting oldest user to admin (one-shot, only if no admin exists)" sqlite3 "$DB_PATH" \ "UPDATE User SET isAdmin = 1 \ WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1) \ AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);" fi if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|lastLoginAt|"; then log "adding missing column User.lastLoginAt (nullable)" sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;" fi if ! sqlite3 "$DB_PATH" \ "SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \ 2>/dev/null | grep -q InstanceSettings; then log "creating InstanceSettings table + singleton row (signupsOpen=0)" sqlite3 "$DB_PATH" \ "CREATE TABLE InstanceSettings ( \ id INTEGER PRIMARY KEY DEFAULT 1, \ signupsOpen INTEGER NOT NULL DEFAULT 0, \ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP \ );" sqlite3 "$DB_PATH" \ "INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);" fi # SQLite tuning. Enabling WAL means readers don't block on a concurrent # writer (and vice versa) — crucial for the "background StartOS Backup # while users are using the app" case, which under the default rollback # journal can produce a torn snapshot. journal_mode persists in the DB # header once set, so this is effectively a one-shot. synchronous=NORMAL # is the safe-with-WAL balance: no fsync after every commit but still # crash-consistent at every checkpoint, ~10x faster than FULL. current_mode=$(sqlite3 "$DB_PATH" "PRAGMA journal_mode;" 2>/dev/null || echo "") if [ "$current_mode" != "wal" ]; then log "switching SQLite journal_mode from '${current_mode:-unknown}' to WAL" sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" >/dev/null fi sqlite3 "$DB_PATH" "PRAGMA synchronous=NORMAL;" >/dev/null # Composite indexes for hot query paths. CREATE INDEX IF NOT EXISTS is # idempotent — no-op if Prisma already created them via db push on a # fresh install. These are also declared in schema.prisma so any future # `prisma db push` keeps them in sync. log "ensuring composite indexes are present" sqlite3 "$DB_PATH" " CREATE INDEX IF NOT EXISTS Session_userId_expiresAt_idx ON Session(userId, expiresAt); CREATE INDEX IF NOT EXISTS Workout_userId_deletedAt_date_idx ON Workout(userId, deletedAt, date); CREATE INDEX IF NOT EXISTS SetLog_workoutId_setNumber_idx ON SetLog(workoutId, setNumber); " >/dev/null fi # ----------------------------------------------------------------------------- # Step 3 — ensure curated exercise library for every user (multi-user-aware). # New entries shipped in /app/prisma/exercises.seed.json appear on every boot. # `INSERT OR IGNORE` keyed on (userId, name) so we never overwrite a user's # own custom exercises. Designed to be additive only — exercises removed from # the curated JSON are not deleted from existing installs (users may have # logged sets against them). # ----------------------------------------------------------------------------- if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then log "ensuring curated exercise library is present for every user" node /app/prisma/ensureExerciseLibrary.cjs \ --db "$DB_PATH" \ --json "$LIBRARY_JSON_PATH" \ || log "WARNING: ensureExerciseLibrary failed; continuing boot" else log "skipping library ensure (json or db not found)" fi # ----------------------------------------------------------------------------- # Step 4 — launch the app. # ----------------------------------------------------------------------------- export DATABASE_URL="file:$DB_PATH" export NODE_ENV="${NODE_ENV:-production}" export HOSTNAME="${HOSTNAME:-0.0.0.0}" export PORT="${PORT:-3000}" log "launching Next.js on :${PORT} with DATABASE_URL=file:${DB_PATH}" exec node /app/server.js