initial deployment v1.0
This commit is contained in:
72
scripts/create-admin.js
Normal file
72
scripts/create-admin.js
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Create or update an admin user.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/create-admin.js <username> <password>
|
||||
*
|
||||
* If the username already exists, the password is updated (upsert).
|
||||
* Run this directly on the server after deployment.
|
||||
*
|
||||
* Example:
|
||||
* node scripts/create-admin.js raine supersecretpassword
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { config } from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// Load .env from project root
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
config({ path: join(__dirname, '..', '.env') });
|
||||
|
||||
const [,, username, password] = process.argv;
|
||||
|
||||
if (!username || !password) {
|
||||
console.error('Usage: node scripts/create-admin.js <username> <password>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (username.length < 2 || username.length > 32) {
|
||||
console.error('Username must be between 2 and 32 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
console.error('Password must be at least 8 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dbPath = process.env.DATABASE_URL ?? './dev.db';
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Ensure the admins table exists (safe to run before full app init)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const existing = db.prepare(`SELECT id FROM admins WHERE username = ?`).get(username);
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE admins SET password_hash = ? WHERE username = ?`).run(hash, username);
|
||||
console.log(`✓ Password updated for admin: ${username}`);
|
||||
} else {
|
||||
const id = randomBytes(8).toString('hex');
|
||||
db.prepare(`INSERT INTO admins (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)`)
|
||||
.run(id, username, hash, now);
|
||||
console.log(`✓ Admin created: ${username}`);
|
||||
}
|
||||
|
||||
db.close();
|
||||
389
scripts/deploy.sh
Normal file
389
scripts/deploy.sh
Normal file
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# ETC PRS — Initial Deployment Script
|
||||
# Targets: Ubuntu 24.04 LTS on a fresh Digital Ocean droplet
|
||||
# Usage: sudo bash deploy.sh
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}▸ $*${RESET}"; }
|
||||
success() { echo -e "${GREEN}✓ $*${RESET}"; }
|
||||
warn() { echo -e "${YELLOW}⚠ $*${RESET}"; }
|
||||
error() { echo -e "${RED}✗ $*${RESET}"; exit 1; }
|
||||
header() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
|
||||
divider() { echo -e "${CYAN}─────────────────────────────────────────────────${RESET}"; }
|
||||
|
||||
# ── Root check ────────────────────────────────────────────────────────────────
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
error "This script must be run as root (sudo bash deploy.sh)"
|
||||
fi
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────────────────
|
||||
clear
|
||||
echo -e "${BOLD}${CYAN}"
|
||||
echo " ███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗"
|
||||
echo " ██╔════╝╚══██╔══╝██╔════╝ ██╔══██╗██╔══██╗██╔════╝"
|
||||
echo " █████╗ ██║ ██║ ██████╔╝██████╔╝███████╗"
|
||||
echo " ██╔══╝ ██║ ██║ ██╔═══╝ ██╔══██╗╚════██║"
|
||||
echo " ███████╗ ██║ ╚██████╗ ██║ ██║ ██║███████║"
|
||||
echo " ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝"
|
||||
echo -e "${RESET}"
|
||||
echo -e " ${BOLD}Personality Editor — Initial Deployment${RESET}"
|
||||
echo -e " ${CYAN}Targeting Ubuntu 24.04 · Node 20 · PM2 · Nginx · Certbot${RESET}"
|
||||
divider
|
||||
echo ""
|
||||
|
||||
# ── Interactive prompts ───────────────────────────────────────────────────────
|
||||
header "Configuration"
|
||||
|
||||
# Domain
|
||||
while true; do
|
||||
read -rp "$(echo -e "${BOLD}Domain name${RESET} (e.g. etcprs.app): ")" DOMAIN
|
||||
DOMAIN="${DOMAIN#www.}" # strip leading www. if provided
|
||||
DOMAIN="${DOMAIN%/}" # strip trailing slash
|
||||
[[ -n "$DOMAIN" ]] && break
|
||||
warn "Domain cannot be empty."
|
||||
done
|
||||
|
||||
# Gitea repo
|
||||
echo ""
|
||||
read -rp "$(echo -e "${BOLD}Gitea repo path${RESET} [default: raine/etc-prs-ui]: ")" REPO_PATH
|
||||
REPO_PATH="${REPO_PATH:-raine/etc-prs-ui}"
|
||||
REPO_URL="https://git.etcprs.app/${REPO_PATH}.git"
|
||||
echo -e " ${CYAN}→ ${REPO_URL}${RESET}"
|
||||
|
||||
# Gitea credentials (for private repos)
|
||||
echo ""
|
||||
echo -e "${YELLOW}If your Gitea repo is private, enter credentials below."
|
||||
echo -e "Leave blank if the repo is public.${RESET}"
|
||||
read -rp "$(echo -e "${BOLD}Gitea username${RESET} (blank = public): ")" GITEA_USER
|
||||
if [[ -n "$GITEA_USER" ]]; then
|
||||
read -rsp "$(echo -e "${BOLD}Gitea password / token${RESET}: ")" GITEA_PASS
|
||||
echo ""
|
||||
REPO_URL="https://${GITEA_USER}:${GITEA_PASS}@git.etcprs.app/${REPO_PATH}.git"
|
||||
fi
|
||||
|
||||
# Admin account
|
||||
echo ""
|
||||
echo -e "${BOLD}Create the first admin account:${RESET}"
|
||||
while true; do
|
||||
read -rp " Admin username (2–32 chars): " ADMIN_USER
|
||||
[[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break
|
||||
warn "Username must be 2–32 characters."
|
||||
done
|
||||
while true; do
|
||||
read -rsp " Admin password (min 8 chars): " ADMIN_PASS
|
||||
echo ""
|
||||
[[ ${#ADMIN_PASS} -ge 8 ]] && break
|
||||
warn "Password must be at least 8 characters."
|
||||
done
|
||||
read -rsp " Confirm password: " ADMIN_PASS_CONFIRM
|
||||
echo ""
|
||||
[[ "$ADMIN_PASS" == "$ADMIN_PASS_CONFIRM" ]] || error "Passwords do not match."
|
||||
|
||||
# Rate limits
|
||||
echo ""
|
||||
echo -e "${BOLD}Rate limits${RESET} (press Enter to accept defaults):"
|
||||
read -rp " Publish rate limit per IP/hour [default: 5]: " RATE_PUBLISH
|
||||
RATE_PUBLISH="${RATE_PUBLISH:-5}"
|
||||
read -rp " Read rate limit per IP/hour [default: 100]: " RATE_READ
|
||||
RATE_READ="${RATE_READ:-100}"
|
||||
|
||||
# SSL email
|
||||
echo ""
|
||||
read -rp "$(echo -e "${BOLD}Email for SSL certificate${RESET} (Let's Encrypt notices): ")" SSL_EMAIL
|
||||
[[ -n "$SSL_EMAIL" ]] || error "SSL email is required."
|
||||
|
||||
# ── Confirmation ──────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
divider
|
||||
echo -e "${BOLD} Deployment Summary${RESET}"
|
||||
divider
|
||||
echo -e " Domain: ${CYAN}${DOMAIN}${RESET} (and www.${DOMAIN})"
|
||||
echo -e " Repo: ${CYAN}${REPO_URL//:*@/:***@}${RESET}"
|
||||
echo -e " Admin user: ${CYAN}${ADMIN_USER}${RESET}"
|
||||
echo -e " Rate limits: publish=${RATE_PUBLISH}/hr read=${RATE_READ}/hr"
|
||||
echo -e " SSL email: ${CYAN}${SSL_EMAIL}${RESET}"
|
||||
divider
|
||||
echo ""
|
||||
read -rp "$(echo -e "${BOLD}Proceed with deployment? [y/N]: ${RESET}")" CONFIRM
|
||||
[[ "${CONFIRM,,}" == "y" ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
APP_USER="prs"
|
||||
APP_DIR="/opt/etc-prs/app"
|
||||
DATA_DIR="/var/lib/etc-prs"
|
||||
LOG_DIR="/var/log/etc-prs"
|
||||
BACKUP_DIR="/var/backups/etc-prs"
|
||||
BACKUP_SCRIPT="/opt/etc-prs/backup.sh"
|
||||
NGINX_CONF="/etc/nginx/sites-available/etc-prs"
|
||||
|
||||
# ── Step 1: System packages ───────────────────────────────────────────────────
|
||||
header "Step 1 / 9 — System Packages"
|
||||
|
||||
info "Updating package lists…"
|
||||
apt-get update -qq
|
||||
|
||||
info "Upgrading installed packages…"
|
||||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
|
||||
|
||||
info "Installing build tools, Nginx, Certbot, SQLite…"
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
build-essential python3 git nginx certbot python3-certbot-nginx sqlite3 ufw curl
|
||||
|
||||
info "Installing Node.js 20 LTS via NodeSource…"
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs
|
||||
|
||||
NODE_VER=$(node --version)
|
||||
NPM_VER=$(npm --version)
|
||||
success "Node ${NODE_VER} / npm ${NPM_VER} installed"
|
||||
|
||||
info "Installing PM2 globally…"
|
||||
npm install -g pm2 --quiet
|
||||
success "PM2 $(pm2 --version) installed"
|
||||
|
||||
# ── Step 2: Users & directories ───────────────────────────────────────────────
|
||||
header "Step 2 / 9 — Users & Directories"
|
||||
|
||||
if ! id "$APP_USER" &>/dev/null; then
|
||||
info "Creating system user '${APP_USER}'…"
|
||||
useradd --system --shell /bin/false --home /opt/etc-prs "$APP_USER"
|
||||
success "User '${APP_USER}' created"
|
||||
else
|
||||
success "User '${APP_USER}' already exists"
|
||||
fi
|
||||
|
||||
for DIR in /opt/etc-prs "$APP_DIR" "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"; do
|
||||
mkdir -p "$DIR"
|
||||
done
|
||||
|
||||
chown -R "${APP_USER}:${APP_USER}" /opt/etc-prs "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"
|
||||
success "Directories created and ownership set"
|
||||
|
||||
# ── Step 3: Clone from Gitea ──────────────────────────────────────────────────
|
||||
header "Step 3 / 9 — Clone Repository"
|
||||
|
||||
info "Cloning from ${REPO_URL//:*@/:***@}…"
|
||||
|
||||
if [[ -d "${APP_DIR}/.git" ]]; then
|
||||
warn "App directory already contains a git repo — pulling latest instead."
|
||||
sudo -u "$APP_USER" git -C "$APP_DIR" pull
|
||||
else
|
||||
sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR" 2>&1 | \
|
||||
sed 's/'"${GITEA_PASS:-NOPASS}"'/***REDACTED***/g' || \
|
||||
error "Git clone failed. Check your repo URL and credentials."
|
||||
fi
|
||||
|
||||
success "Repository cloned to ${APP_DIR}"
|
||||
|
||||
# ── Step 4: Environment file ──────────────────────────────────────────────────
|
||||
header "Step 4 / 9 — Environment Configuration"
|
||||
|
||||
ENV_FILE="${APP_DIR}/.env"
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
DATABASE_URL=${DATA_DIR}/personalities.db
|
||||
RATE_LIMIT_PUBLISH=${RATE_PUBLISH}
|
||||
RATE_LIMIT_READ=${RATE_READ}
|
||||
PUBLIC_BASE_URL=https://${DOMAIN}
|
||||
EOF
|
||||
|
||||
chown "${APP_USER}:${APP_USER}" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
success ".env written to ${ENV_FILE}"
|
||||
|
||||
# ── Step 5: Install deps & build ──────────────────────────────────────────────
|
||||
header "Step 5 / 9 — Install & Build"
|
||||
|
||||
info "Installing npm dependencies (this may take a minute)…"
|
||||
sudo -u "$APP_USER" npm --prefix "$APP_DIR" install --quiet
|
||||
|
||||
info "Building SvelteKit app…"
|
||||
sudo -u "$APP_USER" npm --prefix "$APP_DIR" run build
|
||||
|
||||
success "Build complete"
|
||||
|
||||
# ── Step 6: PM2 ───────────────────────────────────────────────────────────────
|
||||
header "Step 6 / 9 — PM2 Process Manager"
|
||||
|
||||
ECOSYSTEM="${APP_DIR}/ecosystem.config.cjs"
|
||||
cat > "$ECOSYSTEM" <<EOF
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'etc-prs',
|
||||
script: '${APP_DIR}/build/index.js',
|
||||
cwd: '${APP_DIR}',
|
||||
user: '${APP_USER}',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: '3000',
|
||||
HOST: '127.0.0.1',
|
||||
},
|
||||
error_file: '${LOG_DIR}/error.log',
|
||||
out_file: '${LOG_DIR}/out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
restart_delay: 3000,
|
||||
max_restarts: 10,
|
||||
}]
|
||||
};
|
||||
EOF
|
||||
|
||||
chown "${APP_USER}:${APP_USER}" "$ECOSYSTEM"
|
||||
|
||||
info "Starting app with PM2…"
|
||||
sudo -u "$APP_USER" pm2 start "$ECOSYSTEM"
|
||||
sudo -u "$APP_USER" pm2 save
|
||||
|
||||
info "Configuring PM2 to start on boot…"
|
||||
# Capture the startup command PM2 emits and run it
|
||||
PM2_STARTUP=$(sudo -u "$APP_USER" pm2 startup systemd -u "$APP_USER" --hp /opt/etc-prs 2>&1 | grep "sudo env")
|
||||
if [[ -n "$PM2_STARTUP" ]]; then
|
||||
eval "$PM2_STARTUP"
|
||||
fi
|
||||
|
||||
success "PM2 configured — app is running on 127.0.0.1:3000"
|
||||
|
||||
# ── Step 7: Nginx ─────────────────────────────────────────────────────────────
|
||||
header "Step 7 / 9 — Nginx"
|
||||
|
||||
cat > "$NGINX_CONF" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN} www.${DOMAIN};
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# Proxy to SvelteKit
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_cache_bypass \$http_upgrade;
|
||||
proxy_read_timeout 30s;
|
||||
client_max_body_size 2M;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Remove default site, enable ours
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/etc-prs
|
||||
|
||||
nginx -t || error "Nginx config test failed — check ${NGINX_CONF}"
|
||||
systemctl reload nginx
|
||||
success "Nginx configured for ${DOMAIN}"
|
||||
|
||||
# ── Step 8: SSL ───────────────────────────────────────────────────────────────
|
||||
header "Step 8 / 9 — SSL Certificate (Let's Encrypt)"
|
||||
|
||||
info "Requesting certificate for ${DOMAIN} and www.${DOMAIN}…"
|
||||
info "DNS must be pointing at this server's IP for this to succeed."
|
||||
echo ""
|
||||
|
||||
if certbot --nginx \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email "$SSL_EMAIL" \
|
||||
--domains "${DOMAIN},www.${DOMAIN}" \
|
||||
--redirect; then
|
||||
success "SSL certificate issued and Nginx updated"
|
||||
info "Verifying auto-renewal…"
|
||||
certbot renew --dry-run --quiet && success "Auto-renewal check passed"
|
||||
else
|
||||
warn "Certbot failed — HTTP is still working but HTTPS is not configured."
|
||||
warn "Once your DNS is pointing here, run:"
|
||||
warn " sudo certbot --nginx -d ${DOMAIN} -d www.${DOMAIN} --email ${SSL_EMAIL} --agree-tos"
|
||||
fi
|
||||
|
||||
# ── Step 9: Backup cron + admin user ─────────────────────────────────────────
|
||||
header "Step 9 / 9 — Backups, Firewall & Admin Account"
|
||||
|
||||
# Backup script
|
||||
cat > "$BACKUP_SCRIPT" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
DATE=$(date +%Y-%m-%d_%H%M)
|
||||
BACKUP_DIR=/var/backups/etc-prs
|
||||
DB_PATH=/var/lib/etc-prs/personalities.db
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
sqlite3 "$DB_PATH" ".backup ${BACKUP_DIR}/personalities-${DATE}.db"
|
||||
gzip "${BACKUP_DIR}/personalities-${DATE}.db"
|
||||
|
||||
# Keep only the last 30 days
|
||||
find "$BACKUP_DIR" -name "personalities-*.db.gz" -mtime +30 -delete
|
||||
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') Backup OK: personalities-${DATE}.db.gz"
|
||||
EOF
|
||||
|
||||
chmod +x "$BACKUP_SCRIPT"
|
||||
|
||||
# Add cron job (2am daily) — idempotent
|
||||
CRON_LINE="0 2 * * * ${BACKUP_SCRIPT} >> ${LOG_DIR}/backup.log 2>&1"
|
||||
( crontab -l 2>/dev/null | grep -v "$BACKUP_SCRIPT"; echo "$CRON_LINE" ) | crontab -
|
||||
success "Backup cron job scheduled (daily at 2am)"
|
||||
|
||||
# Firewall
|
||||
info "Configuring UFW firewall…"
|
||||
ufw allow OpenSSH > /dev/null
|
||||
ufw allow 'Nginx Full' > /dev/null
|
||||
ufw --force enable > /dev/null
|
||||
success "Firewall enabled (SSH + HTTP + HTTPS)"
|
||||
|
||||
# Admin account
|
||||
info "Creating admin account '${ADMIN_USER}'…"
|
||||
cd "$APP_DIR"
|
||||
sudo -u "$APP_USER" node scripts/create-admin.js "$ADMIN_USER" "$ADMIN_PASS" || \
|
||||
warn "Admin creation failed — run manually: cd ${APP_DIR} && node scripts/create-admin.js <user> <pass>"
|
||||
success "Admin account '${ADMIN_USER}' created"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
divider
|
||||
echo -e "${BOLD}${GREEN} Deployment complete!${RESET}"
|
||||
divider
|
||||
echo ""
|
||||
echo -e " ${BOLD}Site:${RESET} https://${DOMAIN}"
|
||||
echo -e " ${BOLD}Admin panel:${RESET} https://${DOMAIN}/admin"
|
||||
echo -e " ${BOLD}App logs:${RESET} sudo -u ${APP_USER} pm2 logs etc-prs"
|
||||
echo -e " ${BOLD}DB path:${RESET} ${DATA_DIR}/personalities.db"
|
||||
echo -e " ${BOLD}Backups:${RESET} ${BACKUP_DIR}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Redeploy after a push:${RESET}"
|
||||
echo -e " ${CYAN} cd ${APP_DIR} && sudo -u ${APP_USER} git pull && \\"
|
||||
echo -e " sudo -u ${APP_USER} npm install && \\"
|
||||
echo -e " sudo -u ${APP_USER} npm run build && \\"
|
||||
echo -e " sudo -u ${APP_USER} pm2 reload etc-prs${RESET}"
|
||||
echo ""
|
||||
divider
|
||||
echo ""
|
||||
|
||||
# ── Verify app is responding ──────────────────────────────────────────────────
|
||||
info "Checking app is responding on port 3000…"
|
||||
sleep 2
|
||||
if curl -sf http://127.0.0.1:3000 > /dev/null; then
|
||||
success "App is up and responding ✓"
|
||||
else
|
||||
warn "App did not respond on port 3000. Check logs:"
|
||||
warn " sudo -u ${APP_USER} pm2 logs etc-prs --lines 30"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
273
scripts/import-personalities.js
Normal file
273
scripts/import-personalities.js
Normal file
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bulk import personalities from a JSON manifest + directory of .prs files.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/import-personalities.js <json-file> <prs-dir> [options]
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Print what would be imported without writing to DB
|
||||
* --creator Creator handle to tag all imports with (default: "ETC Library")
|
||||
* --skip-existing Skip fixtures already in DB (matched by prs_name + manufacturer)
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/import-personalities.js personalities.json ./prs
|
||||
* node scripts/import-personalities.js personalities.json ./prs --dry-run
|
||||
* node scripts/import-personalities.js personalities.json ./prs --creator "Raine"
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { config } from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
config({ path: join(__dirname, '..', '.env') });
|
||||
|
||||
// ── Args ─────────────────────────────────────────────────────────
|
||||
const args = process.argv.slice(2);
|
||||
const jsonPath = args[0];
|
||||
const prsDir = args[1];
|
||||
const DRY_RUN = args.includes('--dry-run');
|
||||
const SKIP_EXISTING = args.includes('--skip-existing');
|
||||
const creatorIdx = args.indexOf('--creator');
|
||||
const CREATOR = creatorIdx !== -1 ? args[creatorIdx + 1] : 'ETC Library';
|
||||
|
||||
if (!jsonPath || !prsDir) {
|
||||
console.error('Usage: node scripts/import-personalities.js <json-file> <prs-dir> [--dry-run] [--skip-existing] [--creator <name>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(jsonPath)) {
|
||||
console.error(`JSON file not found: ${jsonPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(prsDir)) {
|
||||
console.error(`PRS directory not found: ${prsDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────
|
||||
const FILE_SIZE = 540; // ETC PRS files are always 540 bytes
|
||||
const NAME_LEN = 12;
|
||||
const NAME_OFFSET = 0;
|
||||
|
||||
function readPrsName(bytes) {
|
||||
const raw = bytes.slice(NAME_OFFSET, NAME_OFFSET + NAME_LEN);
|
||||
let name = '';
|
||||
for (const b of raw) {
|
||||
if (b === 0) break;
|
||||
name += String.fromCharCode(b);
|
||||
}
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
// ── DB setup ─────────────────────────────────────────────────────
|
||||
const dbPath = process.env.DATABASE_URL ?? './dev.db';
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Ensure base table exists (safe no-op if already present)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS personalities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
prs_name TEXT,
|
||||
file_name TEXT,
|
||||
notes TEXT,
|
||||
data BLOB NOT NULL,
|
||||
manufacturer TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
channel_count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
creator_handle TEXT,
|
||||
view_count INTEGER NOT NULL DEFAULT 0,
|
||||
owner_token_hash TEXT NOT NULL,
|
||||
deleted_at TEXT DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Add prs_name column if it doesn't exist yet
|
||||
const cols = db.prepare('PRAGMA table_info(personalities)').all().map(r => r.name);
|
||||
if (!cols.includes('prs_name')) {
|
||||
db.exec('ALTER TABLE personalities ADD COLUMN prs_name TEXT DEFAULT NULL');
|
||||
console.log(' ℹ Added prs_name column to existing DB.');
|
||||
}
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO personalities
|
||||
(id, name, prs_name, file_name, notes, data, manufacturer, tags,
|
||||
channel_count, created_at, creator_handle, owner_token_hash)
|
||||
VALUES
|
||||
(@id, @name, @prs_name, @file_name, @notes, @data, @manufacturer, @tags,
|
||||
@channel_count, @created_at, @creator_handle, @owner_token_hash)
|
||||
`);
|
||||
|
||||
const existsStmt = db.prepare(`
|
||||
SELECT id FROM personalities
|
||||
WHERE prs_name = ? AND manufacturer = ? AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
// ── Slug helper ──────────────────────────────────────────────────
|
||||
function makeSlug(str) {
|
||||
return str.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
// ── Load manifest ─────────────────────────────────────────────────
|
||||
const manifest = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
||||
console.log(`\nETC PRS Bulk Import`);
|
||||
console.log(`${'─'.repeat(50)}`);
|
||||
console.log(` Manifest: ${jsonPath}`);
|
||||
console.log(` PRS dir: ${resolve(prsDir)}`);
|
||||
console.log(` Database: ${dbPath}`);
|
||||
console.log(` Creator: ${CREATOR}`);
|
||||
console.log(` Dry run: ${DRY_RUN ? 'YES — nothing will be written' : 'no'}`);
|
||||
console.log(` Total packs: ${manifest.total_packs}`);
|
||||
console.log(` Total PRS: ${manifest.total_fixtures}`);
|
||||
console.log(`${'─'.repeat(50)}\n`);
|
||||
|
||||
// ── Import ───────────────────────────────────────────────────────
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let missing = 0;
|
||||
let errors = 0;
|
||||
let existing = 0;
|
||||
|
||||
// Use a single bcrypt hash for all imports (avoids 160 × slow bcrypt calls)
|
||||
// Each entry gets a unique nanoid token; we hash one representative value.
|
||||
// In practice these are bulk "library" entries — owner-token deletion is
|
||||
// less relevant, but we still store a valid hash so the schema stays consistent.
|
||||
const sharedTokenBase = nanoid(32);
|
||||
const sharedTokenHash = DRY_RUN ? 'dryrun' : bcrypt.hashSync(sharedTokenBase, 10);
|
||||
|
||||
const doImport = db.transaction((records) => {
|
||||
for (const r of records) {
|
||||
insertStmt.run(r);
|
||||
}
|
||||
});
|
||||
|
||||
const batchRecords = [];
|
||||
|
||||
for (const pack of manifest.packs) {
|
||||
const { manufacturer, category, fixtures, prs_files } = pack;
|
||||
|
||||
if (!fixtures || fixtures.length === 0) {
|
||||
console.log(` ⚪ ${manufacturer} — no fixtures, skipping pack`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` 📦 ${pack.name} (${fixtures.length} fixtures)`);
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { fixture_name, channels, mode_info, prs_file } = fixture;
|
||||
|
||||
// Find the matching .prs filename from prs_files list
|
||||
const prsFileName = prs_files.find(f =>
|
||||
f.toLowerCase().includes(prs_file.toLowerCase())
|
||||
);
|
||||
|
||||
if (!prsFileName) {
|
||||
console.log(` ✗ ${fixture_name} — no matching PRS file for key "${prs_file}"`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const prsPath = join(prsDir, prsFileName);
|
||||
if (!existsSync(prsPath)) {
|
||||
console.log(` ✗ ${fixture_name} — file not found: ${prsFileName}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read and validate binary
|
||||
const data = readFileSync(prsPath);
|
||||
if (data.length !== FILE_SIZE) {
|
||||
console.log(` ✗ ${fixture_name} — invalid file size ${data.length} (expected ${FILE_SIZE})`);
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read PRS name from binary
|
||||
const prsName = readPrsName(data);
|
||||
|
||||
// Check for existing entry
|
||||
if (SKIP_EXISTING) {
|
||||
const dup = existsStmt.get(prsName, manufacturer);
|
||||
if (dup) {
|
||||
console.log(` ~ ${fixture_name} — already in DB, skipping`);
|
||||
existing++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Build display name: "Fixture Name (mode_info)" if mode_info available
|
||||
const displayName = mode_info
|
||||
? `${fixture_name} (${mode_info})`
|
||||
: fixture_name;
|
||||
|
||||
// Tags: mode_info + category
|
||||
const tags = [];
|
||||
if (mode_info) tags.push(mode_info.toLowerCase());
|
||||
if (category) tags.push(category.toLowerCase());
|
||||
|
||||
const channelCount = channels ?? data[0x0d]; // use JSON value or read from binary
|
||||
const now = new Date().toISOString();
|
||||
const id = nanoid(10);
|
||||
|
||||
const record = {
|
||||
id,
|
||||
name: displayName.slice(0, 120),
|
||||
prs_name: prsName.slice(0, NAME_LEN),
|
||||
file_name: prsFileName,
|
||||
notes: '',
|
||||
data: data,
|
||||
manufacturer: manufacturer,
|
||||
tags: JSON.stringify(tags),
|
||||
channel_count: channelCount,
|
||||
created_at: now,
|
||||
creator_handle: CREATOR,
|
||||
owner_token_hash: sharedTokenHash,
|
||||
};
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` ✓ [DRY] ${displayName} — ${channelCount}ch — PRS: ${prsName} — ${prsFileName}`);
|
||||
} else {
|
||||
batchRecords.push(record);
|
||||
console.log(` ✓ ${displayName} — ${channelCount}ch — PRS: ${prsName}`);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Write all records in a single transaction
|
||||
if (!DRY_RUN && batchRecords.length > 0) {
|
||||
doImport(batchRecords);
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
if (DRY_RUN) {
|
||||
console.log(` DRY RUN — no changes made to database`);
|
||||
console.log(` Would import: ${imported}`);
|
||||
} else {
|
||||
console.log(` ✓ Imported: ${imported}`);
|
||||
}
|
||||
if (existing > 0) console.log(` ~ Skipped (existing): ${existing}`);
|
||||
if (skipped > 0) console.log(` ⚪ Skipped: ${skipped}`);
|
||||
if (missing > 0) console.log(` ✗ Missing: ${missing}`);
|
||||
if (errors > 0) console.log(` ✗ Errors: ${errors}`);
|
||||
console.log(`${'─'.repeat(50)}\n`);
|
||||
|
||||
db.close();
|
||||
Reference in New Issue
Block a user