Initial build: Great Bear microbrewery site
- TanStack Start (React 19, Tailwind 4) frontend recreated from Stitch design - PocketBase backend with schema migration + idempotent content seed - Single Docker image running both services (non-root, healthchecked) - deploy.sh to build & push to registry.tanshu.com - Ansible playbook (roles: app, caddy) deploying to www.greatbear.in with Caddy TLS entries for the site and the PocketBase admin
This commit is contained in:
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-off helper used during development to create the Great Bear collections
|
||||
# on a running PocketBase instance via the admin API.
|
||||
# Usage: PB_URL=http://127.0.0.1:8099 PB_EMAIL=... PB_PASSWORD=... ./scripts/create-collections.sh
|
||||
set -euo pipefail
|
||||
|
||||
PB_URL="${PB_URL:-http://127.0.0.1:8099}"
|
||||
PB_EMAIL="${PB_EMAIL:?PB_EMAIL required}"
|
||||
PB_PASSWORD="${PB_PASSWORD:?PB_PASSWORD required}"
|
||||
|
||||
TOKEN=$(curl -sS -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
|
||||
echo "authenticated: ${TOKEN:0:12}..."
|
||||
|
||||
WORKDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
cd "$WORKDIR"
|
||||
|
||||
content_rules='"listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null'
|
||||
# Reservations: visitors may create (forms) but only admins may read/update.
|
||||
reservation_rules='"listRule":null,"viewRule":null,"createRule":"","updateRule":null,"deleteRule":null'
|
||||
mk() { NAME=$1; FIELDS=$2; IDX=$3; RULES=${4:-$content_rules}; cat > "$NAME.json" <<EOF
|
||||
{"name":"$NAME","type":"base",$RULES,"fields":$FIELDS,"indexes":$IDX}
|
||||
EOF
|
||||
}
|
||||
|
||||
mk brews '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"name","type":"text","required":true},
|
||||
{"name":"style","type":"text","required":true},
|
||||
{"name":"short_name","type":"text"},
|
||||
{"name":"abv","type":"number"},
|
||||
{"name":"ibu","type":"number"},
|
||||
{"name":"description","type":"text"},
|
||||
{"name":"tasting_notes","type":"text"},
|
||||
{"name":"pairing","type":"text"},
|
||||
{"name":"image","type":"text"},
|
||||
{"name":"featured","type":"bool"},
|
||||
{"name":"flagship","type":"bool"},
|
||||
{"name":"on_tap","type":"bool"},
|
||||
{"name":"seasonal","type":"bool"},
|
||||
{"name":"seasonal_icon","type":"text"},
|
||||
{"name":"flight","type":"bool"},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_brews_uid ON brews (uid)"]'
|
||||
|
||||
mk stats '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"value","type":"text","required":true},
|
||||
{"name":"label","type":"text","required":true},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_stats_uid ON stats (uid)"]'
|
||||
|
||||
mk experiences '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"title","type":"text","required":true},
|
||||
{"name":"description","type":"text"},
|
||||
{"name":"icon","type":"text"},
|
||||
{"name":"image","type":"text"},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_experiences_uid ON experiences (uid)"]'
|
||||
|
||||
mk testimonials '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"name","type":"text","required":true},
|
||||
{"name":"role","type":"text"},
|
||||
{"name":"quote","type":"text","required":true},
|
||||
{"name":"rating","type":"number"},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_testimonials_uid ON testimonials (uid)"]'
|
||||
|
||||
mk menu_categories '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"slug","type":"text","required":true},
|
||||
{"name":"name","type":"text","required":true},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_menu_categories_uid ON menu_categories (uid)","CREATE UNIQUE INDEX idx_menu_categories_slug ON menu_categories (slug)"]'
|
||||
|
||||
mk menu_items '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"category","type":"text","required":true},
|
||||
{"name":"name","type":"text","required":true},
|
||||
{"name":"description","type":"text"},
|
||||
{"name":"price","type":"number"},
|
||||
{"name":"pairing","type":"text"},
|
||||
{"name":"tag","type":"text"},
|
||||
{"name":"image","type":"text"},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_menu_items_uid ON menu_items (uid)"]'
|
||||
|
||||
mk pairings '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"title","type":"text","required":true},
|
||||
{"name":"description","type":"text"},
|
||||
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_pairings_uid ON pairings (uid)"]'
|
||||
|
||||
mk settings '[
|
||||
{"name":"uid","type":"text","required":true},
|
||||
{"name":"brand","type":"text","required":true},
|
||||
{"name":"footer_about","type":"text"},
|
||||
{"name":"address_line","type":"text"},
|
||||
{"name":"city_line","type":"text"},
|
||||
{"name":"hours_line","type":"text"},
|
||||
{"name":"hours_multiline","type":"text"},
|
||||
{"name":"phone_primary","type":"text"},
|
||||
{"name":"phone_secondary","type":"text"},
|
||||
{"name":"instagram_url","type":"text"},
|
||||
{"name":"instagram_handle","type":"text"},
|
||||
{"name":"maps_query","type":"text"}]' '["CREATE UNIQUE INDEX idx_settings_uid ON settings (uid)"]'
|
||||
|
||||
mk reservations '[
|
||||
{"name":"name","type":"text","required":true},
|
||||
{"name":"phone","type":"text","required":true},
|
||||
{"name":"source","type":"text","required":true},
|
||||
{"name":"date","type":"text"},
|
||||
{"name":"time","type":"text"},
|
||||
{"name":"guests","type":"text"},
|
||||
{"name":"requests","type":"text"},
|
||||
{"name":"status","type":"select","maxSelect":1,"values":["new","confirmed","cancelled"]}]' '[]' "$reservation_rules"
|
||||
|
||||
for NAME in brews stats experiences testimonials menu_categories menu_items pairings settings reservations; do
|
||||
CODE=$(curl -sS -o "resp_$NAME.json" -w "%{http_code}" -X POST "$PB_URL/api/collections" \
|
||||
-H "Authorization: $TOKEN" -H "Content-Type: application/json" -d @"$NAME.json")
|
||||
echo "$NAME -> HTTP $CODE"
|
||||
if [ "$CODE" != "200" ]; then cat "resp_$NAME.json"; echo; fi
|
||||
done
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Container entrypoint supervisor.
|
||||
*
|
||||
* Runs PocketBase and the TanStack Start node server side by side:
|
||||
* 1. starts PocketBase (data in /data, migrations from ./pb_migrations)
|
||||
* 2. waits for its health endpoint
|
||||
* 3. bootstraps the superuser account (idempotent upsert)
|
||||
* 4. seeds initial content (idempotent, create-if-missing)
|
||||
* 5. starts the site server on PORT (default 3000)
|
||||
*
|
||||
* If either process dies the container exits non-zero so Docker restarts it.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
|
||||
const PORT = process.env.PORT ?? '3000'
|
||||
const PB_HTTP = process.env.PB_HTTP ?? '0.0.0.0:8090'
|
||||
const PB_DATA = process.env.PB_DATA ?? '/data'
|
||||
|
||||
let stopping = false
|
||||
const children = []
|
||||
|
||||
function start(name, command, args, extraEnv = {}) {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...extraEnv },
|
||||
})
|
||||
child.name = name
|
||||
children.push(child)
|
||||
child.on('exit', (code) => {
|
||||
if (stopping) return
|
||||
console.error(`[supervisor] ${name} exited with code ${code} — shutting down`)
|
||||
shutdown(code ?? 1)
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
function run(name, command, args, extraEnv = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...extraEnv },
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
console.warn(`[supervisor] ${name} finished with status ${result.status}`)
|
||||
}
|
||||
return result.status ?? 0
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0) {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM')
|
||||
}
|
||||
setTimeout(() => process.exit(exitCode), 2000)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0))
|
||||
process.on('SIGTERM', () => shutdown(0))
|
||||
|
||||
async function waitForHealth(url, attempts = 120) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok) return
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw new Error(`health check at ${url} timed out`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[supervisor] starting PocketBase…')
|
||||
start('pocketbase', './pocketbase', [
|
||||
'serve',
|
||||
`--http=${PB_HTTP}`,
|
||||
`--dir=${PB_DATA}`,
|
||||
'--migrationsDir=./pb_migrations',
|
||||
'--publicDir=./pb_public',
|
||||
])
|
||||
|
||||
await waitForHealth('http://127.0.0.1:8090/api/health')
|
||||
console.log('[supervisor] PocketBase is healthy')
|
||||
|
||||
if (process.env.PB_SUPERUSER_EMAIL && process.env.PB_SUPERUSER_PASSWORD) {
|
||||
run('superuser upsert', './pocketbase', [
|
||||
'superuser',
|
||||
'upsert',
|
||||
process.env.PB_SUPERUSER_EMAIL,
|
||||
process.env.PB_SUPERUSER_PASSWORD,
|
||||
`--dir=${PB_DATA}`,
|
||||
'--migrationsDir=./pb_migrations',
|
||||
])
|
||||
console.log('[supervisor] superuser ensured')
|
||||
|
||||
run('seed', 'node', ['scripts/seed.mjs'])
|
||||
} else {
|
||||
console.warn('[supervisor] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping superuser + seed')
|
||||
}
|
||||
|
||||
console.log(`[supervisor] starting site server on :${PORT}…`)
|
||||
start('site', 'node', ['.output/server/index.mjs'], {
|
||||
PORT,
|
||||
PB_URL: process.env.PB_URL ?? 'http://127.0.0.1:8090',
|
||||
NODE_ENV: 'production',
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[supervisor] fatal: ${error.message}`)
|
||||
shutdown(1)
|
||||
})
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Idempotent content seeder.
|
||||
*
|
||||
* Reads content.json (same file the frontend bundles as offline fallback)
|
||||
* and creates any record that is missing in PocketBase, identified by its
|
||||
* `uid` field. Existing records are NEVER overwritten — after the first
|
||||
* seed, PocketBase owns the data and content is managed via the admin UI
|
||||
* (admin.greatbear.in).
|
||||
*
|
||||
* Required env: PB_SUPERUSER_EMAIL, PB_SUPERUSER_PASSWORD. Optional: PB_URL.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const PB_URL = process.env.PB_URL ?? 'http://127.0.0.1:8090'
|
||||
const EMAIL = process.env.PB_SUPERUSER_EMAIL
|
||||
const PASSWORD = process.env.PB_SUPERUSER_PASSWORD
|
||||
|
||||
if (!EMAIL || !PASSWORD) {
|
||||
console.error('[seed] PB_SUPERUSER_EMAIL/PB_SUPERUSER_PASSWORD not set — skipping seed')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const content = JSON.parse(readFileSync(join(here, 'content.json'), 'utf8'))
|
||||
|
||||
async function auth() {
|
||||
const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: EMAIL, password: PASSWORD }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`superuser auth failed (HTTP ${res.status})`)
|
||||
const json = await res.json()
|
||||
return json.token
|
||||
}
|
||||
|
||||
async function ensureRecord(collection, token, item) {
|
||||
const filter = encodeURIComponent(`uid="${item.uid}"`)
|
||||
const found = await fetch(
|
||||
`${PB_URL}/api/collections/${collection}/records?filter=${filter}`,
|
||||
{ headers: { Authorization: token } },
|
||||
)
|
||||
if (!found.ok) throw new Error(`lookup failed for ${collection}/${item.uid} (HTTP ${found.status})`)
|
||||
const list = await found.json()
|
||||
if (list.items.length > 0) return 'exists'
|
||||
|
||||
const created = await fetch(`${PB_URL}/api/collections/${collection}/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item),
|
||||
})
|
||||
if (!created.ok) {
|
||||
const body = await created.text()
|
||||
throw new Error(`create failed for ${collection}/${item.uid} (HTTP ${created.status}): ${body}`)
|
||||
}
|
||||
return 'created'
|
||||
}
|
||||
|
||||
const COLLECTIONS = [
|
||||
'settings',
|
||||
'stats',
|
||||
'brews',
|
||||
'experiences',
|
||||
'testimonials',
|
||||
'menu_categories',
|
||||
'menu_items',
|
||||
'pairings',
|
||||
]
|
||||
|
||||
try {
|
||||
const token = await auth()
|
||||
let created = 0
|
||||
let existing = 0
|
||||
for (const collection of COLLECTIONS) {
|
||||
const items = Array.isArray(content[collection])
|
||||
? content[collection]
|
||||
: content[collection]
|
||||
? [content[collection]]
|
||||
: []
|
||||
for (const item of items) {
|
||||
const result = await ensureRecord(collection, token, item)
|
||||
if (result === 'created') created++
|
||||
else existing++
|
||||
}
|
||||
}
|
||||
console.log(`[seed] done: ${created} created, ${existing} already present`)
|
||||
} catch (error) {
|
||||
console.error(`[seed] failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user