diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 00000000..18ee97f2 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,9 @@ +# Deployment secrets +# Copy to .env.production and fill in values + +# Path to SQLite database file +DB_PATH=/data/db.sqlite3 + +# Your site URL (auto-filled from app name, change if using custom domain) +ORIGIN=https://editable-test.fly.dev + diff --git a/.gitignore b/.gitignore index 6598d400..4602880a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ Thumbs.db .env.* !.env.example !.env.test +!.env.production.example # Data diff --git a/README.md b/README.md index 191d8c17..8f812c6b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,74 @@ npm run dev This is v2, a complete rewrite using [Svedit](https://github.com/michael/svedit). It's under active development — feel free to explore locally, but hold off on production deployments for now. +## Deploying to Fly.io + +### Prerequisites + +1. Install the Fly CLI: https://fly.io/docs/flyctl/install/ +2. Sign up or log in: `fly auth login` + +### First-time setup + +Run the init command to create your deployment configuration: + +```sh +npm run deploy:init +``` + +This will ask for your app name and region, then create: +- `fly.toml` — Fly.io configuration +- `Dockerfile` — Container build instructions +- `.env.production.example` — Template for your secrets + +Copy the example and add your values: + +```sh +cp .env.production.example .env.production +``` + +Edit `.env.production` with your actual values (this file is gitignored). + +### Deploy + +Build and deploy your site: + +```sh +npm run build +npm run deploy +``` + +Your site will be live at `https://your-app-name.fly.dev` + +### Updating secrets + +When you add new secrets to `deploy/config.js`: + +1. Add the value to `.env.production` +2. Run `npm run deploy:secrets` + +### Custom domain + +```sh +fly certs add yourdomain.com +``` + +Then configure DNS to point to your Fly.io app: + +| Type | Name | Value | +|-------|------|--------------------------| +| CNAME | www | your-app-name.fly.dev | + +For the root domain (@), use one of these options: +- **ALIAS/ANAME** (if your DNS supports it): Point to `your-app-name.fly.dev` +- **A/AAAA records**: Run `fly ips list` and create A (IPv4) and AAAA (IPv6) records + +## Deploying to other platforms + +This repo is configured for Fly.io deployment by default (using `@sveltejs/adapter-node`). + +**For Vercel or Cloudflare Pages:** You'll need to switch the adapter in `svelte.config.js`. + ## Looking for v1? Find it [here](https://github.com/michael/editable-website/tree/v1). diff --git a/deploy/config.js b/deploy/config.js new file mode 100644 index 00000000..92b9c4e1 --- /dev/null +++ b/deploy/config.js @@ -0,0 +1,31 @@ +/** + * Deployment configuration + * + * This file defines the secrets schema for your application. + * Values go in .env.production (which is gitignored). + * + * Add new secrets here as your app grows, then run: + * npm run deploy:secrets + */ + +export default { + secrets: { + DB_PATH: { + required: true, + default: '/data/db.sqlite3', + validate: (v) => v.endsWith('.sqlite3') || v.endsWith('.db'), + hint: 'Path to SQLite database file', + }, + ORIGIN: { + required: true, + validate: (v) => v.startsWith('https://') && !v.endsWith('/'), + hint: 'Your site URL (auto-filled from app name, change if using custom domain)', + }, + // Add more secrets as needed: + // AUTH_SECRET: { + // required: true, + // validate: (v) => v.length >= 32, + // hint: 'Random string for session encryption (min 32 chars)', + // }, + }, +}; diff --git a/deploy/deploy.js b/deploy/deploy.js new file mode 100644 index 00000000..5e1d6f68 --- /dev/null +++ b/deploy/deploy.js @@ -0,0 +1,516 @@ +#!/usr/bin/env node +/** + * Deployment script for editable-website + * + * Usage: + * node deploy/deploy.js init # First-time setup (interactive) + * node deploy/deploy.js deploy # Build and deploy to Fly.io + * node deploy/deploy.js secrets # Sync secrets to Fly.io + * + * Flags: + * --app=NAME App name (for non-interactive init) + * --region=CODE Region code (for non-interactive init) + */ + +import { execSync, spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +// ============================================================================= +// Helpers +// ============================================================================= + +function print_success(msg) { console.log(`\x1b[32m✓ ${msg}\x1b[0m`); } +function print_error(msg) { console.log(`\x1b[31m✗ ${msg}\x1b[0m`); } +function print_info(msg) { console.log(`\x1b[36mℹ ${msg}\x1b[0m`); } + +function exec_sync(cmd) { + try { + return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { + return null; + } +} + +function exec_live(cmd, args) { + return new Promise((resolve) => { + const proc = spawn(cmd, args, { stdio: 'inherit' }); + proc.on('close', (code) => resolve(code || 0)); + }); +} + +function parse_env_file(path) { + if (!existsSync(path)) return {}; + const content = readFileSync(path, 'utf-8'); + const env = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +async function prompt(rl, question) { + return new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer.trim())); + }); +} + +// ============================================================================= +// Validation +// ============================================================================= + +async function load_config() { + const config_path = join(__dirname, 'config.js'); + const config = await import(config_path); + return config.default; +} + +function check_gitignore() { + const gitignore_path = join(ROOT, '.gitignore'); + if (!existsSync(gitignore_path)) { + print_error('.gitignore not found'); + return false; + } + const content = readFileSync(gitignore_path, 'utf-8'); + // Parse gitignore lines (exclude comments and empty lines) + const lines = content.split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')); + + // Check for patterns that would ignore .env.production + // Valid patterns: ".env.production", ".env.*", ".env*" + const ignores_env_production = lines.some(line => + line === '.env.production' || + line === '.env.*' || + line === '.env*' + ); + + if (!ignores_env_production) { + print_error('.env.production is NOT in .gitignore!'); + print_info('This file contains secrets and must never be committed.'); + print_info('Add ".env.production" to your .gitignore file.'); + return false; + } + return true; +} + +function validate_app_name(name) { + // Fly.io app names: lowercase, alphanumeric, hyphens, 3-63 chars + if (!name || name.length < 3 || name.length > 63) { + return 'App name must be 3-63 characters'; + } + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(name)) { + return 'App name must be lowercase, alphanumeric, may include hyphens (not at start/end)'; + } + return null; // Valid +} + +function validate_secrets(config, env) { + const errors = []; + for (const [key, schema] of Object.entries(config.secrets)) { + const value = env[key]; + if (schema.required && !value) { + errors.push(`${key}: Required but not set. ${schema.hint || ''}`); + continue; + } + if (value && schema.validate && !schema.validate(value)) { + errors.push(`${key}: Invalid value "${value}". ${schema.hint || ''}`); + } + } + return errors; +} + +// ============================================================================= +// Fly.io Adapter +// ============================================================================= + +const fly = { + check_cli() { + return exec_sync('which flyctl') !== null || exec_sync('which fly') !== null; + }, + + check_auth() { + return exec_sync('fly auth whoami') !== null; + }, + + app_exists(name) { + const result = exec_sync(`fly apps list --json`); + if (!result) return false; + try { + const apps = JSON.parse(result); + return apps.some(app => app.Name === name); + } catch { + return false; + } + }, + + async create_app(name) { + // Region is handled by fly.toml's primary_region during deploy + const code = await exec_live('fly', ['apps', 'create', name, '--machines']); + return code === 0; + }, + + async set_secrets(name, secrets) { + const args = ['secrets', 'set', '-a', name]; + for (const [key, value] of Object.entries(secrets)) { + if (value) args.push(`${key}=${value}`); + } + const code = await exec_live('fly', args); + return code === 0; + }, + + async deploy() { + const code = await exec_live('fly', ['deploy']); + return code === 0; + }, +}; + +// ============================================================================= +// Commands +// ============================================================================= + +async function cmd_init(flags) { + console.log('\n Fly.io Deployment Setup\n'); + + // Check prerequisites + if (!fly.check_cli()) { + print_error('Fly CLI not found'); + print_info('Install: curl -L https://fly.io/install.sh | sh'); + process.exit(1); + } + + if (!fly.check_auth()) { + print_error('Not logged in to Fly.io'); + print_info('Run: fly auth login'); + process.exit(1); + } + + let app_name = flags.app; + let region = flags.region || 'fra'; + + // Interactive mode if flags not provided + if (!app_name) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + app_name = await prompt(rl, ' App name: '); + if (!app_name) { + print_error('App name is required'); + rl.close(); + process.exit(1); + } + + const name_error = validate_app_name(app_name); + if (name_error) { + print_error(name_error); + rl.close(); + process.exit(1); + } + + const region_input = await prompt(rl, ' Region [fra]: '); + if (region_input) region = region_input; + + // Check if files already exist + const existing_files = [ + existsSync(join(ROOT, 'fly.toml')) && 'fly.toml', + existsSync(join(ROOT, 'Dockerfile')) && 'Dockerfile', + ].filter(Boolean); + + if (existing_files.length > 0) { + console.log(`\n Files already exist: ${existing_files.join(', ')}`); + const confirm = await prompt(rl, ' Overwrite? [y/N]: '); + if (confirm.toLowerCase() !== 'y') { + print_info('Init cancelled'); + rl.close(); + process.exit(0); + } + } + + rl.close(); + } else { + // Non-interactive mode: validate app name + const name_error = validate_app_name(app_name); + if (name_error) { + print_error(name_error); + process.exit(1); + } + + // Check for existing files + const existing_files = [ + existsSync(join(ROOT, 'fly.toml')) && 'fly.toml', + existsSync(join(ROOT, 'Dockerfile')) && 'Dockerfile', + ].filter(Boolean); + + if (existing_files.length > 0 && !flags.force) { + print_error(`Files already exist: ${existing_files.join(', ')}`); + print_info('Use --force to overwrite'); + process.exit(1); + } + } + + console.log(''); + + // Copy templates to root + const template_dir = join(__dirname, 'templates', 'fly'); + + // fly.toml + let fly_toml = readFileSync(join(template_dir, 'fly.toml'), 'utf-8'); + fly_toml = fly_toml.replace(/\{\{APP_NAME\}\}/g, app_name); + fly_toml = fly_toml.replace(/\{\{REGION\}\}/g, region); + writeFileSync(join(ROOT, 'fly.toml'), fly_toml); + print_success('Created fly.toml'); + + // Dockerfile + copyFileSync(join(template_dir, 'Dockerfile'), join(ROOT, 'Dockerfile')); + print_success('Created Dockerfile'); + + // .env.production.example + const config = await load_config(); + let env_example = '# Deployment secrets\n# Copy to .env.production and fill in values\n\n'; + for (const [key, schema] of Object.entries(config.secrets)) { + if (schema.hint) env_example += `# ${schema.hint}\n`; + // Derive ORIGIN from app name + let default_value = schema.default || ''; + if (key === 'ORIGIN') { + default_value = `https://${app_name}.fly.dev`; + } + env_example += `${key}=${default_value}\n\n`; + } + writeFileSync(join(ROOT, '.env.production.example'), env_example); + print_success('Created .env.production.example'); + + console.log('\n Next steps:\n'); + console.log(' 1. cp .env.production.example .env.production'); + console.log(' 2. Edit .env.production with your values'); + console.log(' 3. npm run build && npm run deploy\n'); +} + +async function cmd_secrets() { + console.log(''); + + // Check prerequisites + if (!fly.check_cli()) { + print_error('Fly CLI not found'); + print_info('Install: curl -L https://fly.io/install.sh | sh'); + process.exit(1); + } + + if (!fly.check_auth()) { + print_error('Not logged in to Fly.io'); + print_info('Run: fly auth login'); + process.exit(1); + } + + if (!check_gitignore()) process.exit(1); + + const env_path = join(ROOT, '.env.production'); + if (!existsSync(env_path)) { + print_error('.env.production not found'); + print_info('Run: cp .env.production.example .env.production'); + process.exit(1); + } + + // Load and validate + const config = await load_config(); + const env = parse_env_file(env_path); + const errors = validate_secrets(config, env); + + if (errors.length > 0) { + print_error('Secrets validation failed:\n'); + for (const err of errors) console.log(` • ${err}`); + console.log(''); + process.exit(1); + } + + print_success('Secrets validated'); + + // Get app name from fly.toml + const fly_toml_path = join(ROOT, 'fly.toml'); + if (!existsSync(fly_toml_path)) { + print_error('fly.toml not found. Run: npm run deploy:init'); + process.exit(1); + } + + const fly_toml = readFileSync(fly_toml_path, 'utf-8'); + const app_match = fly_toml.match(/app\s*=\s*['"]([^'"]+)['"]/); + if (!app_match) { + print_error('Could not read app name from fly.toml'); + process.exit(1); + } + const app_name = app_match[1]; + + // Check app exists on Fly + if (!fly.app_exists(app_name)) { + print_error(`App "${app_name}" not found on Fly.io`); + print_info('Run: npm run deploy first (it will create the app)'); + process.exit(1); + } + + // Sync secrets + print_info(`Syncing secrets to ${app_name}...`); + const success = await fly.set_secrets(app_name, env); + + if (success) { + print_success('Secrets synced to Fly.io'); + } else { + print_error('Failed to sync secrets'); + process.exit(1); + } +} + +async function cmd_deploy() { + console.log(''); + + // Check prerequisites + if (!fly.check_cli()) { + print_error('Fly CLI not found'); + print_info('Install: curl -L https://fly.io/install.sh | sh'); + process.exit(1); + } + + if (!fly.check_auth()) { + print_error('Not logged in to Fly.io'); + print_info('Run: fly auth login'); + process.exit(1); + } + + if (!check_gitignore()) process.exit(1); + + // Check fly.toml exists + const fly_toml_path = join(ROOT, 'fly.toml'); + if (!existsSync(fly_toml_path)) { + print_error('fly.toml not found'); + print_info('Run: npm run deploy:init'); + process.exit(1); + } + + // Validate secrets + const env_path = join(ROOT, '.env.production'); + if (!existsSync(env_path)) { + print_error('.env.production not found'); + print_info('Run: cp .env.production.example .env.production'); + process.exit(1); + } + + const config = await load_config(); + const env = parse_env_file(env_path); + const errors = validate_secrets(config, env); + + if (errors.length > 0) { + print_error('Secrets validation failed:\n'); + for (const err of errors) console.log(` • ${err}`); + console.log(''); + process.exit(1); + } + + print_success('Secrets validated'); + + // Get app name + const fly_toml = readFileSync(fly_toml_path, 'utf-8'); + const app_match = fly_toml.match(/app\s*=\s*['"]([^'"]+)['"]/); + if (!app_match) { + print_error('Could not read app name from fly.toml'); + process.exit(1); + } + const app_name = app_match[1]; + + // Check if app exists, create if not + if (!fly.app_exists(app_name)) { + print_info(`App "${app_name}" not found on Fly.io, creating...`); + + if (!await fly.create_app(app_name)) { + print_error('Failed to create app'); + process.exit(1); + } + print_success(`Created app "${app_name}"`); + + // Set secrets on first deploy + print_info('Setting secrets...'); + if (!await fly.set_secrets(app_name, env)) { + print_error('Failed to set secrets'); + process.exit(1); + } + print_success('Secrets configured'); + } + + // Deploy + print_info('Deploying to Fly.io...'); + console.log(''); + + const success = await fly.deploy(); + + console.log(''); + if (success) { + print_success(`Deployed to https://${app_name}.fly.dev`); + } else { + print_error('Deployment failed'); + print_info(`Check logs: fly logs -a ${app_name}`); + process.exit(1); + } +} + +// ============================================================================= +// CLI Entry Point +// ============================================================================= + +function parse_flags(args) { + const flags = {}; + for (const arg of args) { + if (arg.startsWith('--')) { + const [key, value] = arg.slice(2).split('='); + flags[key] = value || true; + } + } + return flags; +} + +const args = process.argv.slice(2); +const command = args.find(a => !a.startsWith('--')); +const flags = parse_flags(args); + +switch (command) { + case 'init': + cmd_init(flags); + break; + case 'secrets': + cmd_secrets(); + break; + case 'deploy': + cmd_deploy(); + break; + default: + console.log(` + Usage: node deploy/deploy.js + + Commands: + init First-time setup (creates fly.toml, Dockerfile) + deploy Build and deploy to Fly.io + secrets Validate and sync secrets to Fly.io + + Flags (for init): + --app=NAME App name + --region=CODE Region code (default: fra) + --force Overwrite existing files + + Examples: + npm run deploy:init + npm run deploy:init -- --app=my-site --region=fra + npm run deploy + npm run deploy:secrets +`); +} diff --git a/deploy/templates/fly/Dockerfile b/deploy/templates/fly/Dockerfile new file mode 100644 index 00000000..075986d3 --- /dev/null +++ b/deploy/templates/fly/Dockerfile @@ -0,0 +1,19 @@ +# Dockerfile for Fly.io deployment +# Docs: https://fly.io/docs/languages-and-frameworks/dockerfile/ + +FROM node:22-slim +WORKDIR /app + +# Install production dependencies +COPY package*.json ./ +RUN npm ci --omit=dev + +# Copy built application and scripts +COPY build ./build +COPY scripts ./scripts + +# Create data directory for SQLite volume +RUN mkdir -p /data + +EXPOSE 3000 +CMD ["node", "scripts/start.js"] diff --git a/deploy/templates/fly/fly.toml b/deploy/templates/fly/fly.toml new file mode 100644 index 00000000..73a5269c --- /dev/null +++ b/deploy/templates/fly/fly.toml @@ -0,0 +1,23 @@ +# Fly.io configuration +# Docs: https://fly.io/docs/reference/configuration/ + +app = '{{APP_NAME}}' +primary_region = '{{REGION}}' + +[build] + +[mounts] + source = 'data' + destination = '/data' + +[http_service] + internal_port = 3000 + force_https = true + auto_start_machines = true + auto_stop_machines = 'stop' + min_machines_running = 0 + +[[vm]] + memory = '512mb' + cpu_kind = 'shared' + cpus = 1 diff --git a/package-lock.json b/package-lock.json index f1ebb037..85d58129 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.1", - "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/forms": "^0.5.10", @@ -760,6 +760,112 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -1126,14 +1232,20 @@ "acorn": "^8.9.0" } }, - "node_modules/@sveltejs/adapter-auto": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.1.1.tgz", - "integrity": "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ==", + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.1.tgz", + "integrity": "sha512-VpZdPNRPQuZRtgfAMETPWWKpZx9JwXmUUsgz/+eSpw/Oh7+2O1uZHlsQTuyfxydJHPrRzjfu/ItcJjY4oscCiQ==", "dev": true, "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.9.5" + }, "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^2.4.0" } }, "node_modules/@sveltejs/kit": { @@ -1546,6 +1658,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1717,6 +1836,13 @@ "dev": true, "license": "MIT" }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2082,6 +2208,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2197,6 +2330,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2240,6 +2383,19 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2277,6 +2433,22 @@ "node": ">=0.8.19" } }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2300,6 +2472,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2879,6 +3058,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2892,7 +3078,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3199,6 +3384,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3215,6 +3421,7 @@ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -3361,6 +3568,19 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svedit": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/svedit/-/svedit-0.6.1.tgz", diff --git a/package.json b/package.json index 0da0f9ed..f6d91995 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,15 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", "format": "prettier --write .", - "lint": "prettier --check . && eslint ." + "lint": "prettier --check . && eslint .", + "deploy": "node deploy/deploy.js deploy", + "deploy:init": "node deploy/deploy.js init", + "deploy:secrets": "node deploy/deploy.js secrets" }, "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.1", - "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/forms": "^0.5.10", diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 00000000..10afb172 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,29 @@ +/** + * Production entry point for Fly.io + * + * This script: + * 1. Runs database migrations (if enabled) + * 2. Starts the SvelteKit server + * 3. Handles graceful shutdown on SIGTERM/SIGINT + */ + +// Uncomment when migrations are ready: +// import migrate from '../src/sqlite/migrate.js'; + +// Run migrations before starting +// console.log('Running migrations...'); +// migrate(); +// console.log('Migrations complete'); + +// Graceful shutdown handler +function shutdown(signal) { + console.log(`\nReceived ${signal}, shutting down gracefully...`); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +// Start SvelteKit server (this starts listening and keeps the process alive) +console.log('Starting server...'); +await import('../build/index.js'); diff --git a/svelte.config.js b/svelte.config.js index 023baf0d..e8290e76 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; /** @type {import('@sveltejs/kit').Config} */ const config = {